From ce6b7310c81333e1f3ae70d025922d47d83d4b59 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:28:05 +0300 Subject: [PATCH 1/6] fix(native): scope a declaration's style target to its own iteration applyDeclarations reassigned its target parameter per declaration, and a deferred declaration's closure captured that shared binding. The closure runs after every declaration in the rule has been walked, so it read whatever nested target the last declaration ended on: a deferred color beside a box-shadow checked its placeholder against the shadow entry, missed it, and left the internal { color: true } in the style. -webkit-line-clamp: var(--n) left { numberOfLines: true } on the prop the same way. Each declaration now walks into a binding of its own. --- .../native/deferred-declarations.test.tsx | 67 +++++++++++++++++++ src/native/styles/calculate-props.ts | 29 ++++---- 2 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/native/deferred-declarations.test.tsx diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..01a084d1 --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,67 @@ +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; }`; + +function renderText(css: string) { + 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); + }); +}); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..44abaf53 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -105,14 +105,13 @@ export function applyDeclarations( target: Record = {}, topLevelTarget = target, ) { - 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; if (!Array.isArray(declaration)) { // Static styles - Object.assign(target, declaration); + Object.assign(declarationTarget, declaration); } else { // Dynamic styles let value: any = declaration[0]; @@ -131,7 +130,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,19 +142,19 @@ 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 { @@ -186,19 +185,19 @@ export function applyDeclarations( renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); }); } else { delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; + if (getDeepPath(declarationTarget, prop) === value) { + delete declarationTarget[prop]; value = resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } }); } @@ -211,7 +210,7 @@ export function applyDeclarations( }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } } From 9819d3d8c79229b0c009bfa095755eac05bb2b9e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:31:09 +0300 Subject: [PATCH 2/6] fix(native): keep a later declaration's keys from a deferred shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deferred declaration resolves after every other declaration of the element, so a shorthand that resolved at runtime spread its keys over whatever a later declaration had set: border: var(--b) followed by border-color: blue painted the variable's colour, within a rule and across rules, and so did border-inline-width: var(--w) over a later border-inline-start-width. Each key belongs to the declaration that set it last (css-cascade-5 §6.4). Each element now records, per target and key, the cascade position of its last writer. A deferred shorthand learns its keys only when it resolves, so it claims them one by one and keeps the ones a later declaration holds. --- .../native/deferred-declarations.test.tsx | 50 +++++- src/native/styles/calculate-props.ts | 160 +++++++++++++----- 2 files changed, 167 insertions(+), 43 deletions(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 01a084d1..cf3a4599 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,12 +3,12 @@ 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; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; }`; -function renderText(css: string) { +function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); - return render().getByTestId( + return render().getByTestId( testID, ).props; } @@ -65,3 +65,47 @@ describe("a deferred declaration beside a nested one", () => { 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 44abaf53..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,13 +174,19 @@ export function applyDeclarations( guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, + cascade: Cascade = createCascade(), ) { for (const declaration of declarations) { // 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 + for (const key of Object.keys(declaration)) { + claim(cascade, declarationTarget, key, position); + } + Object.assign(declarationTarget, declaration); } else { // Dynamic styles @@ -161,56 +237,60 @@ export function applyDeclarations( 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(declarationTarget, prop, value); + transformStyles.push(() => { + value = resolveValue(originalValue, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - } else { - delayedStyles.push(() => { - if (getDeepPath(declarationTarget, prop) === value) { - delete declarationTarget[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(declarationTarget, prop, value); - } - }); - } - } else { - value = resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, + }), + position, + ); + } }); - } - applyValue(declarationTarget, prop, value); + applyInCascade(cascade, declarationTarget, prop, placeholder, position); + } else { + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }), + position, + ); + } } } } From a406b34d79a24ef1aa02da9e292450e56c4c23ac Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 16:41:59 +0300 Subject: [PATCH 3/6] test(native): pin a shadow with no colour beside a colour A shadow that omits its colour takes currentcolor, which resolves as a deferred declaration inside the shadow. On main its placeholder reaches the style: textShadowColor: { textShadowColor: true } for a colourless text-shadow beside a color, and color: { color: true } on a colourless box-shadow entry under an inherited colour. --- .../native/deferred-declarations.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index cf3a4599..a4a8402b 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -64,6 +64,49 @@ describe("a deferred declaration beside a nested one", () => { expect(props.style).toStrictEqual({ color: "red" }); expect(props.numberOfLines).toBe(2); }); + + test("a text-shadow with no colour takes the element's colour", () => { + expect( + renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style, + ).toStrictEqual({ + color: "#00f", + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }); + }); + + test("a box-shadow's first shadow with no colour takes the inherited colour", () => { + registerCSS( + `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`, + ); + + const child = render( + + + , + ).getByTestId(testID); + + expect(child.props.style).toStrictEqual({ + boxShadow: [ + { + color: "#123456", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + inset: true, + }, + { + color: "#654321", + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); }); // 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 From 1027ae75926c2beca3badb36cc0d470dbd8585fc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:09:33 +0300 Subject: [PATCH 4/6] fix(native): hold a key a later path passes through A later `text-shadow` writes its offset through `textShadowOffset.width` and `.height`, and only the leaf was claimed, so an earlier `text-shadow: var()` that resolved late still replaced `textShadowOffset` whole: `.earlier { --s: 2px 2px blue; text-shadow: var(--s) }` before `.my-class { text-shadow: 1px 1px red }` drew the red shadow at the blue offset. A declaration written through a path now claims each key it passes through. --- .../native/deferred-declarations.test.tsx | 22 ++++++++++++++++++- src/native/styles/calculate-props.ts | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index a4a8402b..02c151a4 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,7 +3,7 @@ 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; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; }`; function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); @@ -148,6 +148,26 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { "my-class", { borderStartWidth: 2, borderEndWidth: 3 }, ], + [ + "text-shadow: var() then text-shadow, across rules", + ".earlier { --s: 2px 2px blue; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + "earlier my-class", + { + textShadowColor: "#f00", + textShadowRadius: 0, + textShadowOffset: { width: 1, height: 1 }, + }, + ], + [ + "text-shadow then text-shadow: var(), across rules", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px blue; text-shadow: var(--s); }", + "earlier my-class", + { + textShadowColor: "blue", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }, + ], ] 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 af92b8db..71d61c64 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -224,6 +224,8 @@ export function applyDeclarations( previousTarget = declarationTarget; previousProp = prop; + // A shorthand that resolves late writes this key whole, so a path through it holds it too + claim(cascade, declarationTarget, prop, position); declarationTarget[prop] ??= {}; declarationTarget = declarationTarget[prop]; } From 1167e3adb90dc501a5d05e06d8293bae2a550602 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:25:20 +0300 Subject: [PATCH 5/6] test(native): give the deferred text shadows a hex colour The two text-shadow rows are about which declaration wins, so the colour a variable carries is written in the form a runtime reader keeps whether it serialises a named colour or not. --- src/__tests__/native/deferred-declarations.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 02c151a4..5a2b8319 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -150,7 +150,7 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { ], [ "text-shadow: var() then text-shadow, across rules", - ".earlier { --s: 2px 2px blue; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", "earlier my-class", { textShadowColor: "#f00", @@ -160,10 +160,10 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { ], [ "text-shadow then text-shadow: var(), across rules", - ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px blue; text-shadow: var(--s); }", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }", "earlier my-class", { - textShadowColor: "blue", + textShadowColor: "#00f", textShadowRadius: 0, textShadowOffset: { width: 2, height: 2 }, }, From 2507a93dc4ddcc1a0a68a1bc3f5463b280ccb9d5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 04:19:32 +0300 Subject: [PATCH 6/6] fix(native): resolve every declaration in cascade order once the element's variables are assigned A declaration that reads a var() was resolved after the walk, through a placeholder at its key, so the walk had to remember which later declaration set each key to keep a deferred shorthand off it. A placeholder is also what an individual transform property replaced with an empty list, so `.a { transform: var(--t) } .b { rotate: 45deg }` lost the transform. Every rule's variables are now assigned before any declaration resolves, and every declaration resolves in cascade order, so a later declaration overwrites an earlier one as the browsers apply them and the key claims are gone. A placeholder is left only for an individual transform property, which composes into the transform list once every rule is walked. A runtime value that resolves to nothing still resets the property. --- src/__tests__/native/animations.test.tsx | 16 ++ .../native/deferred-declarations.test.tsx | 88 +++++++- src/compiler/compiler.types.ts | 2 +- src/native/styles/calculate-props.ts | 191 ++++-------------- 4 files changed, 146 insertions(+), 151 deletions(-) diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index c9565d16..b5875d9d 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -133,6 +133,22 @@ test("an invalid numeric animation name is omitted without throwing", () => { expect(style()?.animationName).toBeUndefined(); }); +// Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it +test.each([undefined, false] as const)( + "a written animation-duration after a variable animation applies with inlineVariables=%s", + (inlineVariables) => { + registerCSS( + `${keyframes} .other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); } .later { animation-duration: 2s; }`, + { inlineVariables }, + ); + render(); + expect(style()).toMatchObject({ + animationName: horizontal, + animationDuration: [2000], + }); + }, +); + test("multiple animation names preserve keyframe order and timing metadata", () => { registerCSS(`${keyframes} .motion { animation: slide-in 1s linear 250ms 2 alternate both, diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 5a2b8319..8c51cc84 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,7 +3,7 @@ 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; --s: 9px 9px green; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`; function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); @@ -13,7 +13,7 @@ function renderText(css: string, className = "my-class") { ).props; } -// A deferred declaration resolves after every other one in its rule, against the target it was declared on +// A declaration that reads a variable resolves against the target it was declared on describe("a deferred declaration beside a nested one", () => { test("color: var() beside a box-shadow", () => { expect( @@ -172,3 +172,87 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { expect(renderText(css, className).style).toStrictEqual(style); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written +test("a deferred transform composes with an individual transform property as a written one does", () => { + registerCSS( + `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`, + ); + + const screen = render( + <> + + + , + ); + + const written = screen.getByTestId("written").props.style as { + transform: unknown[]; + }; + expect(written.transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + expect(screen.getByTestId("held").props.style).toStrictEqual(written); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone +test("a var() with no value resets an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: var(--c-missing); }", + "earlier my-class", + ).style, + ).toStrictEqual({}); +}); + +// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands +test("a written value the runtime cannot read leaves an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: 10 }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it +test.each([ + [ + "before a transform", + ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }", + ], + [ + "after a transform", + ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }", + ], +])( + "an individual transform property declared %s composes with it", + (_order, css) => { + const { transform } = renderText(css, "earlier my-class").style as { + transform: unknown[]; + }; + + expect(transform).toHaveLength(2); + expect(transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property +test("an individual transform property beside a box-shadow", () => { + expect( + renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style, + ).toStrictEqual({ + transform: [{ rotateZ: "45deg" }], + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..38c3c046 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -101,7 +101,7 @@ export type StyleDeclaration = | Record /** A style that needs to be set */ | [StyleDescriptor, string | string[]] - /** A value that can only be computed at runtime, and only after styles have been calculated */ + /** A value that can only be computed at runtime */ | [StyleDescriptor, string | string[], 1]; export type StyleDescriptor = diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 71d61c64..f6dbeade 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -7,13 +7,12 @@ import type { import { Specificity as S } from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; -import { applyValue, getDeepPath } from "../objects"; +import { applyValue } from "../objects"; import { VAR_SYMBOL, type Getter, type VariableContextValue, } from "../reactivity"; -import { ShortHandSymbol } from "./constants"; import { transformKeys } from "./defaults"; import { resolveValue } from "./resolve"; @@ -31,10 +30,9 @@ export function calculateProps( let normal: Record | undefined; let important: Record | undefined; - const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; - const cascade = createCascade(); + // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves for (const rule of rules) { if (VAR_SYMBOL in rule) { if (typeof rule[VAR_SYMBOL] === "string") { @@ -42,48 +40,43 @@ export function calculateProps( } else { Object.assign(inheritedVariables, rule); } - continue; - } - - if (rule.v) { + } else if (rule.v) { for (const variable of rule.v) { inlineVariables[variable[0]] = variable[1]; } } + } - if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); - let target = topLevelTarget; + for (const rule of rules) { + if (VAR_SYMBOL in rule || !rule.d) { + continue; + } - const ruleTarget = rule.target || "style"; + let topLevelTarget = rule.s?.[S.Important] + ? (important ??= {}) + : (normal ??= {}); + let target = topLevelTarget; - if (typeof ruleTarget === "string") { - target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { - target = target[path] ??= {}; - } - } + const ruleTarget = rule.target || "style"; - applyDeclarations( - get, - rule.d, - inlineVariables, - inheritedVariables, - delayedStyles, - transformStyles, - guards, - target, - topLevelTarget, - cascade, - ); + if (typeof ruleTarget === "string") { + target = target[ruleTarget] ??= {}; + } else if (ruleTarget) { + for (const path of ruleTarget) { + target = target[path] ??= {}; + } } - } - for (const delayedStyle of delayedStyles) { - delayedStyle(); + applyDeclarations( + get, + rule.d, + inlineVariables, + inheritedVariables, + transformStyles, + guards, + target, + topLevelTarget, + ); } for (const transformStyle of transformStyles) { @@ -97,96 +90,22 @@ 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[], inlineVariables: InlineVariable, inheritedVariables: VariableContextValue, - delayedStyles: (() => void)[] = [], transformStyles: (() => void)[] = [], guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, - cascade: Cascade = createCascade(), ) { for (const declaration of declarations) { - // Each declaration's own binding, since its deferred closure runs after the walk has moved on + // Each declaration's own binding, since its transform closure runs after the walk has moved on let declarationTarget = target; - const position = cascade.next(); if (!Array.isArray(declaration)) { // Static styles - for (const key of Object.keys(declaration)) { - claim(cascade, declarationTarget, key, position); - } - Object.assign(declarationTarget, declaration); } else { // Dynamic styles @@ -224,8 +143,6 @@ export function applyDeclarations( previousTarget = declarationTarget; previousProp = prop; - // A shorthand that resolves late writes this key whole, so a path through it holds it too - claim(cascade, declarationTarget, prop, position); declarationTarget[prop] ??= {}; declarationTarget = declarationTarget[prop]; } @@ -239,6 +156,7 @@ export function applyDeclarations( prop = propPath; } + // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then if (transformKeys.has(prop)) { const originalValue = value; // An object keyed by the prop lets the transform array find this entry @@ -255,43 +173,20 @@ export function applyDeclarations( }); 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, - }), - position, - ); - } + } else { + value = resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - applyInCascade(cascade, declarationTarget, prop, placeholder, position); - } else { - applyInCascade( - cascade, - declarationTarget, - prop, - resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }), - position, - ); + // A runtime value that resolves to nothing removes the property, as css-variables-1 §3.1 resets one a var() leaves without a value + if (value === undefined && declaration[2]) { + delete declarationTarget[prop]; + } else { + applyValue(declarationTarget, prop, value); + } } } }