diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..cf3a4599 --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,111 @@ +import { render } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; }`; + +function renderText(css: string, className = "my-class") { + registerCSS(`${decoy} ${css}`); + + return render().getByTestId( + testID, + ).props; +} + +// A deferred declaration resolves after every other one in its rule, against the target it was declared on +describe("a deferred declaration beside a nested one", () => { + test("color: var() beside a box-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); + + test("color: var() beside a text-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + textShadowColor: "#00f", + textShadowRadius: 2, + textShadowOffset: { width: 1, height: 1 }, + }); + }); + + test("color: var() beside a prop", () => { + const props = renderText( + `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a deferred prop beside a style", () => { + const props = renderText( + `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves +describe("a deferred shorthand keeps no key a later declaration set", () => { + test.each([ + [ + "border: var() then border-color, in one rule", + ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), in one rule", + ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border: var() then border-color, across rules", + ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), across rules", + ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border-inline-width: var() then border-inline-start-width", + ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }", + "my-class", + { borderStartWidth: 5, borderEndWidth: 3 }, + ], + [ + "border-inline-start-width then border-inline-width: var()", + ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }", + "my-class", + { borderStartWidth: 2, borderEndWidth: 3 }, + ], + ] as const)("%s", (_name, css, className, style) => { + expect(renderText(css, className).style).toStrictEqual(style); + }); +}); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..af92b8db 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -13,6 +13,7 @@ import { type Getter, type VariableContextValue, } from "../reactivity"; +import { ShortHandSymbol } from "./constants"; import { transformKeys } from "./defaults"; import { resolveValue } from "./resolve"; @@ -32,6 +33,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + const cascade = createCascade(); for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -75,6 +77,7 @@ export function calculateProps( guards, target, topLevelTarget, + cascade, ); } } @@ -94,6 +97,73 @@ export function calculateProps( }; } +// The cascade position of the declaration that last set each key of each target, since a deferred declaration resolves after every later one +interface Cascade { + readonly writers: WeakMap>; + readonly next: () => number; +} + +function createCascade(): Cascade { + let position = 0; + + return { writers: new WeakMap(), next: () => position++ }; +} + +function claim( + cascade: Cascade, + target: object, + key: string | number, + position: number, +): boolean { + let writers = cascade.writers.get(target); + + if (writers === undefined) { + writers = new Map(); + cascade.writers.set(target, writers); + } + + const holder = writers.get(key); + + if (holder !== undefined && holder > position) { + return false; + } + + writers.set(key, position); + return true; +} + +function isShorthandObject(value: unknown): value is Record { + return ( + typeof value === "object" && value !== null && ShortHandSymbol in value + ); +} + +// A shorthand learns its keys only once it resolves, so each key is claimed on its own +function applyInCascade( + cascade: Cascade, + target: Record, + prop: string | number, + value: unknown, + position: number, +) { + if (!isShorthandObject(value)) { + if (claim(cascade, target, prop, position)) { + applyValue(target, prop as string, value); + } + return; + } + + const kept: Record = { [ShortHandSymbol]: true }; + + for (const [key, keyValue] of Object.entries(value)) { + if (claim(cascade, target, key, position)) { + kept[key] = keyValue; + } + } + + applyValue(target, prop as string, kept); +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], @@ -104,15 +174,20 @@ export function applyDeclarations( guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, + cascade: Cascade = createCascade(), ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + // Each declaration's own binding, since its deferred closure runs after the walk has moved on + let declarationTarget = target; + const position = cascade.next(); if (!Array.isArray(declaration)) { // Static styles - Object.assign(target, declaration); + for (const key of Object.keys(declaration)) { + claim(cascade, declarationTarget, key, position); + } + + Object.assign(declarationTarget, declaration); } else { // Dynamic styles let value: any = declaration[0]; @@ -131,7 +206,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,75 +218,79 @@ export function applyDeclarations( if (!Array.isArray(previousTarget[previousProp])) { previousTarget[previousProp] = []; - target = previousTarget[previousProp]; + declarationTarget = previousTarget[previousProp]; } } - previousTarget = target; + previousTarget = declarationTarget; previousProp = prop; - target[prop] ??= {}; - target = target[prop]; + declarationTarget[prop] ??= {}; + declarationTarget = declarationTarget[prop]; } prop = final; } else { - target = topLevelTarget; + declarationTarget = topLevelTarget; prop = first; } } else { prop = propPath; } - const shouldDelay = declaration[2]; - - if (shouldDelay || transformKeys.has(prop)) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ + if (transformKeys.has(prop)) { const originalValue = value; - // This needs to be a object with the [prop] so we can discover in transform arrays + // An object keyed by the prop lets the transform array find this entry value = { [prop]: true }; - if (transformKeys.has(prop)) { - transformStyles.push(() => { - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }); - applyValue(target, prop, value); + transformStyles.push(() => { + value = resolveValue(originalValue, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - } else { - delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; - value = resolveValue(originalValue, get, { + applyValue(declarationTarget, prop, value); + }); + + applyValue(declarationTarget, prop, value); + } else if (declaration[2]) { + const originalValue = value; + // Resolved once every declaration is walked; a later declaration of the same key replaces the placeholder + const placeholder = { [prop]: true }; + + delayedStyles.push(() => { + if (getDeepPath(declarationTarget, prop) === placeholder) { + delete declarationTarget[prop]; + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, - }); - applyValue(target, prop, value); - } - }); - } - } else { - value = resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, + }), + position, + ); + } }); - } - applyValue(target, prop, value); + applyInCascade(cascade, declarationTarget, prop, placeholder, position); + } else { + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }), + position, + ); + } } } }