From 206579d22b8aa5b28358b9d42b0253fafcf8d3f8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 12 Aug 2026 23:13:32 +0300 Subject: [PATCH 01/12] fix: honour `inherits: false` on registered custom properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom property registered by an `@property` rule with `inherits: false` does not cascade to descendants (css-properties-values-api-1 §2.2). The descriptor was parsed and discarded, so every custom property inherited unconditionally through `VariableContext`. The visible cost is Tailwind v4, which relies on the descriptor heavily. Its `ring-*` utilities are custom properties composed into a five-variable `box-shadow`, and every `shadow-*` utility — `shadow-none` included — emits that same composition. So an element carrying any shadow utility renders its ANCESTOR's ring around itself, on native only: ← painted a red 2px ring - compiler: `extractPropertyRule` records a non-inheriting name, before the `initial-value` early return — the two descriptors are independent, and Tailwind registers `--tw-ring-color` with no default. - stylesheet: emitted as `vn`, beside the existing `vr` / `vu`. - runtime: `updateRules` skips those names when building the `VariableContext` it publishes. The declaring element is unaffected — it resolves its own `var()` from the rule directly, in `calculateProps`. 8 tests: 3 compiler (recorded with and without an initial value, `inherits: true` not recorded, an unregistered property not recorded) and 5 rendered (does not reach a descendant, the inheriting and unregistered counterparts still do, still applies to the element declaring it, and the ring case above). Each fixture declares its custom properties twice, so the single-definition inliner cannot fold them and the runtime path is the one under test. --- src/__tests__/compiler/property.test.ts | 41 +++++ .../native/non-inheriting-variables.test.tsx | 157 ++++++++++++++++++ src/compiler/compiler.ts | 14 +- src/compiler/compiler.types.ts | 8 + src/compiler/stylesheet.ts | 10 ++ src/jest/index.ts | 4 + src/native-internal/root.ts | 11 ++ src/native-internal/style-collection.ts | 14 +- src/native/react/rules.ts | 13 +- 9 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/native/non-inheriting-variables.test.tsx diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 119c85d7..f48cd911 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -208,6 +208,47 @@ test("@property with repeated single-child unwraps to scalar", () => { expect(vrMap.get("my-offset")).toStrictEqual([[10]]); }); +test("@property inherits: false is recorded, initial value or not", () => { + const compiled = compile(` +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +`); + + const result = compiled.stylesheet(); + // `--tw-ring-color` declares no initial value, so it publishes no root + // variable — but it is still non-inheriting, and that is independent of + // whether it has a default. + expect(result.vn).toStrictEqual(["tw-ring-shadow", "tw-ring-color"]); +}); + +test("@property inherits: true is not recorded", () => { + const compiled = compile(` +@property --my-brand { + syntax: ""; + inherits: true; + initial-value: red; +} +`); + + const result = compiled.stylesheet(); + expect(result.vn).toBeUndefined(); +}); + +test("an unregistered custom property is not recorded", () => { + // Custom properties inherit by default; only an @property rule can opt out. + const compiled = compile(`.my-class { --my-var: 10px; }`); + + const result = compiled.stylesheet(); + expect(result.vn).toBeUndefined(); +}); + test("@property with repeated multi-child preserves array", () => { const compiled = compile(` @property --my-offsets { diff --git a/src/__tests__/native/non-inheriting-variables.test.tsx b/src/__tests__/native/non-inheriting-variables.test.tsx new file mode 100644 index 00000000..cf280a85 --- /dev/null +++ b/src/__tests__/native/non-inheriting-variables.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +const parentTestID = "parent"; + +/** + * A custom property registered with `inherits: false` does not cascade to + * descendants (css-properties-values-api-1 §2.2). + * + * Variable inheritance is otherwise unconditional: `VariableContext` receives + * every custom property an element declares, so a descendant resolves an + * ancestor's private value. Tailwind v4 leans on the descriptor heavily — its + * whole `--tw-*` shadow/ring set is registered non-inheriting precisely so a + * ring on one element cannot reach another element's `box-shadow`. + * + * Every fixture below declares each custom property TWICE. A property with a + * single definition is folded into its consumers at compile time, which never + * reaches the runtime path under test — and real Tailwind output always has + * many definitions (one per `ring-*` / `shadow-*` utility), so two is the + * faithful shape rather than a trick. + */ +test("a non-inheriting custom property does not reach a descendant", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + // The registered initial value is what the child resolves — its ancestor's + // 10px is private to the ancestor. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("an inheriting custom property still reaches a descendant", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an unregistered custom property still inherits", () => { + // No @property rule, so the CSS default applies: custom properties inherit. + registerCSS(` + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a non-inheriting custom property still applies to the element declaring it", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .self { --my-var: 10px; width: var(--my-var); } + .other { --my-var: 20px; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +/** + * The shape this defect actually ships as. + * + * Every Tailwind v4 `shadow-*` utility — `shadow-none` included — emits the + * same five-variable composition, so an element declaring any of them reads + * `var(--tw-ring-shadow)`. With the descriptor ignored, a descendant carrying + * `shadow-none` renders its ANCESTOR's ring around itself. + */ +test("an ancestor's ring does not reach a descendant's box-shadow", () => { + registerCSS(` + @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-color { syntax: "*"; inherits: false; } + + .ring-2 { + --tw-ring-shadow: 0 0 0 2px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-4 { + --tw-ring-shadow: 0 0 0 4px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-red { --tw-ring-color: #fb2c36; } + .ring-blue { --tw-ring-color: #2c36fb; } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 #0000001a; + box-shadow: var(--tw-ring-shadow), var(--tw-shadow); + } + `); + + render( + + + , + ); + + // The ancestor paints its own ring. + expect(screen.getByTestId(parentTestID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + color: "#fb2c36", + }, + ], + }); + + // The descendant paints nothing — every layer it composes is transparent. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 214cd615..18856cf2 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -406,13 +406,21 @@ function extractPropertyRule( propertyRule: PropertyRule, builder: StylesheetBuilder, ) { - const { initialValue, name } = propertyRule; + const { inherits, initialValue, name } = propertyRule; + + const varName = name.startsWith("--") ? name.slice(2) : name; + + // `inherits` is independent of `initial-value`, so it is recorded BEFORE the + // early return below. Tailwind v4 registers several of its internal + // properties with no default (`--tw-ring-color`, `--tw-inset-ring-color`), + // and those are exactly the ones a descendant must not resolve. + if (!inherits) { + builder.addNonInheritedVariable(varName); + } if (initialValue == null) { return; } - - const varName = name.startsWith("--") ? name.slice(2) : name; const value = parsePropertyInitialValue(initialValue, builder); if (value !== undefined) { diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..ecb4b601 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -40,6 +40,14 @@ export interface ReactNativeCssStyleSheet { vr?: RootVariables; /** Universal Variables */ vu?: RootVariables; + /** + * Non-inheriting variables — custom properties registered by an `@property` + * rule with `inherits: false`, which do not cascade to descendants + * (css-properties-values-api-1 §2.2). + * + * Names only, without the leading `--`, matching `StyleRule.v`. + */ + vn?: string[]; } /******************************** Styles ********************************/ diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..25018057 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -63,6 +63,7 @@ export class StylesheetBuilder { ruleSets: Record; rootVariables?: VariableRecord; universalVariables?: VariableRecord; + nonInheritedVariables?: Set; animations?: AnimationRecord; rem: number; ruleOrder: number; @@ -173,6 +174,10 @@ export class StylesheetBuilder { ); } + if (this.shared.nonInheritedVariables?.size) { + stylesheetOptions.vn = [...this.shared.nonInheritedVariables]; + } + if (this.shared.animations) { stylesheetOptions.k = Object.entries(this.shared.animations); } @@ -583,6 +588,11 @@ export class StylesheetBuilder { this.shared.rootVariables[name].push([value]); } + addNonInheritedVariable(name: string) { + this.shared.nonInheritedVariables ??= new Set(); + this.shared.nonInheritedVariables.add(name); + } + newAnimationFrames(name: string) { this.shared.animations ??= {}; diff --git a/src/jest/index.ts b/src/jest/index.ts index cc125390..c2b4d1da 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,6 +4,7 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { nonInheritedVariables } from "react-native-css/native-internal"; import { colorScheme, dimensions } from "../native/reactivity"; @@ -20,6 +21,9 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); + // `inject` accumulates, so a name registered non-inheriting by one test's + // stylesheet would still be filtered for the next one's. + nonInheritedVariables.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme(null); colorScheme.set(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..06562f29 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,6 +32,17 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); +/** + * Custom properties registered `inherits: false`, by name without the leading + * `--` (css-properties-values-api-1 §2.2). + * + * A module-level set rather than a field on `StyleCollection`, matching the + * variable families above: `StyleCollection` is assigned with `??=`, so a field + * added there is absent whenever an earlier copy of the module already claimed + * the global. + */ +export const nonInheritedVariables = new Set(); + rootVariables("__rn-css-rem").set([[14]]); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument rootVariables("__rn-css-color").set([ diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index eff34009..c5523453 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -14,9 +14,13 @@ import { type Observable, type VariableContextValue, } from "../native/reactivity"; -import { rootVariables, universalVariables } from "./root"; +import { + nonInheritedVariables, + rootVariables, + universalVariables, +} from "./root"; -export { rootVariables, universalVariables }; +export { nonInheritedVariables, rootVariables, universalVariables }; interface StyleCollectionType { styles: ReturnType>>; @@ -95,6 +99,12 @@ globalThis.__react_native_css_style_collection ??= { } } + if (options.vn) { + for (const name of options.vn) { + nonInheritedVariables.add(name); + } + } + for (const effect of observableBatch.current) { effect.run(); } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index f85a66f9..8e625f8c 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,6 +1,9 @@ /* eslint-disable */ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; -import { StyleCollection } from "react-native-css/native-internal"; +import { + nonInheritedVariables, + StyleCollection, +} from "react-native-css/native-internal"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; @@ -134,6 +137,14 @@ export function updateRules( } for (const v of rule.v) { + // `variables` is the VariableContext this element PUBLISHES to its + // descendants — the element resolves its own `var()` from the rule + // directly (`calculateProps`), so skipping here withholds the value + // from descendants without affecting the declaring element. + if (nonInheritedVariables.has(v[0])) { + continue; + } + variables![v[0]] = v[1]; } } From 78c697908e3ca333ebf3c27329c7e44067639fe1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 19:24:29 +0300 Subject: [PATCH 02/12] docs: trim comments to the surrounding one-line style The blocks I added restated the pull request description. What is left is only what the code cannot state: the `??=` reason for the module-level set, that rules.ts filters the context published to descendants and not the declaring element's own resolution, and that the fixtures declare each property twice on purpose. --- src/__tests__/compiler/property.test.ts | 4 +-- .../native/non-inheriting-variables.test.tsx | 32 +++---------------- src/compiler/compiler.ts | 5 +-- src/compiler/compiler.types.ts | 8 +---- src/jest/index.ts | 3 +- src/native-internal/root.ts | 11 ++----- src/native/react/rules.ts | 6 ++-- 7 files changed, 13 insertions(+), 56 deletions(-) diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index f48cd911..5b465d57 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -222,9 +222,7 @@ test("@property inherits: false is recorded, initial value or not", () => { `); const result = compiled.stylesheet(); - // `--tw-ring-color` declares no initial value, so it publishes no root - // variable — but it is still non-inheriting, and that is independent of - // whether it has a default. + // --tw-ring-color has no initial value, so it publishes no root variable expect(result.vn).toStrictEqual(["tw-ring-shadow", "tw-ring-color"]); }); diff --git a/src/__tests__/native/non-inheriting-variables.test.tsx b/src/__tests__/native/non-inheriting-variables.test.tsx index cf280a85..729d9899 100644 --- a/src/__tests__/native/non-inheriting-variables.test.tsx +++ b/src/__tests__/native/non-inheriting-variables.test.tsx @@ -4,22 +4,8 @@ import { registerCSS, testID } from "react-native-css/jest"; const parentTestID = "parent"; -/** - * A custom property registered with `inherits: false` does not cascade to - * descendants (css-properties-values-api-1 §2.2). - * - * Variable inheritance is otherwise unconditional: `VariableContext` receives - * every custom property an element declares, so a descendant resolves an - * ancestor's private value. Tailwind v4 leans on the descriptor heavily — its - * whole `--tw-*` shadow/ring set is registered non-inheriting precisely so a - * ring on one element cannot reach another element's `box-shadow`. - * - * Every fixture below declares each custom property TWICE. A property with a - * single definition is folded into its consumers at compile time, which never - * reaches the runtime path under test — and real Tailwind output always has - * many definitions (one per `ring-*` / `shadow-*` utility), so two is the - * faithful shape rather than a trick. - */ +// Every custom property below is declared twice. A property with a single definition is +// inlined into its consumers at compile time and never reaches the runtime path under test test("a non-inheriting custom property does not reach a descendant", () => { registerCSS(` @property --my-var { @@ -38,8 +24,7 @@ test("a non-inheriting custom property does not reach a descendant", () => { , ); - // The registered initial value is what the child resolves — its ancestor's - // 10px is private to the ancestor. + // The child resolves the registered initial value, not the ancestor's 10px expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); }); @@ -97,14 +82,7 @@ test("a non-inheriting custom property still applies to the element declaring it expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); }); -/** - * The shape this defect actually ships as. - * - * Every Tailwind v4 `shadow-*` utility — `shadow-none` included — emits the - * same five-variable composition, so an element declaring any of them reads - * `var(--tw-ring-shadow)`. With the descriptor ignored, a descendant carrying - * `shadow-none` renders its ANCESTOR's ring around itself. - */ +// Every Tailwind v4 shadow-* utility composes var(--tw-ring-shadow), shadow-none included test("an ancestor's ring does not reach a descendant's box-shadow", () => { registerCSS(` @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @@ -150,7 +128,7 @@ test("an ancestor's ring does not reach a descendant's box-shadow", () => { ], }); - // The descendant paints nothing — every layer it composes is transparent. + // The descendant paints nothing, as every layer it composes is transparent expect(screen.getByTestId(testID).props.style).toStrictEqual({ boxShadow: [], }); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 18856cf2..13ad4ae6 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -410,10 +410,7 @@ function extractPropertyRule( const varName = name.startsWith("--") ? name.slice(2) : name; - // `inherits` is independent of `initial-value`, so it is recorded BEFORE the - // early return below. Tailwind v4 registers several of its internal - // properties with no default (`--tw-ring-color`, `--tw-inset-ring-color`), - // and those are exactly the ones a descendant must not resolve. + // Recorded before the early return below, as inherits is independent of initial-value if (!inherits) { builder.addNonInheritedVariable(varName); } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index ecb4b601..79c57c18 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -40,13 +40,7 @@ export interface ReactNativeCssStyleSheet { vr?: RootVariables; /** Universal Variables */ vu?: RootVariables; - /** - * Non-inheriting variables — custom properties registered by an `@property` - * rule with `inherits: false`, which do not cascade to descendants - * (css-properties-values-api-1 §2.2). - * - * Names only, without the leading `--`, matching `StyleRule.v`. - */ + /** Non-Inheriting Variables */ vn?: string[]; } diff --git a/src/jest/index.ts b/src/jest/index.ts index c2b4d1da..bc33841d 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -21,8 +21,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); - // `inject` accumulates, so a name registered non-inheriting by one test's - // stylesheet would still be filtered for the next one's. + // inject accumulates, so names stay registered across tests nonInheritedVariables.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 06562f29..b84c4d71 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,15 +32,8 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); -/** - * Custom properties registered `inherits: false`, by name without the leading - * `--` (css-properties-values-api-1 §2.2). - * - * A module-level set rather than a field on `StyleCollection`, matching the - * variable families above: `StyleCollection` is assigned with `??=`, so a field - * added there is absent whenever an earlier copy of the module already claimed - * the global. - */ +// Module-level like the families above, not a StyleCollection field: StyleCollection is +// assigned with `??=`, so a new field is missing if another copy already claimed the global export const nonInheritedVariables = new Set(); rootVariables("__rn-css-rem").set([[14]]); diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index 8e625f8c..632de3be 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -137,10 +137,8 @@ export function updateRules( } for (const v of rule.v) { - // `variables` is the VariableContext this element PUBLISHES to its - // descendants — the element resolves its own `var()` from the rule - // directly (`calculateProps`), so skipping here withholds the value - // from descendants without affecting the declaring element. + // These are the variables published to descendants. The declaring element + // still resolves its own var() from the rule, in calculateProps if (nonInheritedVariables.has(v[0])) { continue; } From f3f4534cca949ea6e7b1e645c5940c47551c7348 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 22:23:33 +0300 Subject: [PATCH 03/12] fix(native): pin the non-inheriting registry to globalThis, and test the gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was module-scoped in the one file whose sibling registries are globalThis-pinned for exactly this reason. StyleCollection is pinned, so whichever copy of the module wins the global does all the injecting and fills its own Set; a rules.ts bound to the other copy reads an empty one and the filter never fires, which is the original bug back with nothing to indicate why. Reverting the guard turns the new registry test red. Five tests cover behaviour nothing reached. Withholding is now pinned at grandchild depth, so an implementation that only blanked the immediate child fails. A property registered with no initial value resolves its var() fallback on a descendant — the --tw-ring-color shape, which the compiler test asserted and the runtime never did. A descendant that declares the property itself wins. And the jest reset is named as its own subject rather than resting on two earlier tests happening to reuse one variable name and happening to run first. The unregistered-property test declared its variable once, so the inliner erased it and the compiled output was empty — it asserted vn was absent from a stylesheet containing nothing, and survived a mutant that recorded every custom property in the file. It now declares twice and asserts the rules exist. Three compiler cases added: a name recorded once however many rules declare it (with different syntaxes, since lightningcss collapses identical @property blocks before the visitor runs), last-declaration-wins in both orders, and @property inside @media pinned as the known limitation it is. --- src/__tests__/compiler/property.test.ts | 61 ++++++++++++- .../native/non-inheriting-registry.test.ts | 21 +++++ .../native/non-inheriting-variables.test.tsx | 88 +++++++++++++++++++ src/native-internal/root.ts | 16 +++- 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/native/non-inheriting-registry.test.ts diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 5b465d57..f51a6974 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -241,12 +241,71 @@ test("@property inherits: true is not recorded", () => { test("an unregistered custom property is not recorded", () => { // Custom properties inherit by default; only an @property rule can opt out. - const compiled = compile(`.my-class { --my-var: 10px; }`); + // Declared twice on purpose: with one definition the inliner erases it and the + // compiled output is empty, so the assertion would hold for a stylesheet + // containing nothing at all. + const compiled = compile(` +.my-class { --my-var: 10px; } +.other { --my-var: 20px; } +`); const result = compiled.stylesheet(); + expect(result.s).toBeDefined(); expect(result.vn).toBeUndefined(); }); +test("@property records a name once, however many rules declare it", () => { + // Different syntaxes on purpose — lightningcss collapses identical @property + // blocks before the visitor sees them, so an identical pair would not reach + // the Set that does the deduplicating. + const compiled = compile(` +@property --dup { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --dup { + syntax: "*"; + inherits: false; +} +`); + + expect(compiled.stylesheet().vn).toStrictEqual(["dup"]); +}); + +test("the last @property declaration of a name decides inherits", () => { + const inheritsLast = compile(` +@property --flip { syntax: "*"; inherits: false; } +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +`); + expect(inheritsLast.stylesheet().vn).toBeUndefined(); + + const nonInheritingLast = compile(` +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +@property --flip { syntax: "*"; inherits: false; } +`); + expect(nonInheritingLast.stylesheet().vn).toStrictEqual(["flip"]); +}); + +test("@property inside @media is not recorded — a known limitation", () => { + // lightningcss reports the nested rule as type "unknown" and extractRule drops it, + // so neither vn nor vr is emitted. Pinned in both directions: the day extractRule + // learns about a nested @property, vn has to follow it. + const compiled = compile(` +@media (min-width: 100px) { + @property --scoped { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } +} +`); + + const result = compiled.stylesheet(); + expect(result.vn).toBeUndefined(); + expect(result.vr).toBeUndefined(); +}); + test("@property with repeated multi-child preserves array", () => { const compiled = compile(` @property --my-offsets { diff --git a/src/__tests__/native/non-inheriting-registry.test.ts b/src/__tests__/native/non-inheriting-registry.test.ts new file mode 100644 index 00000000..b079630a --- /dev/null +++ b/src/__tests__/native/non-inheriting-registry.test.ts @@ -0,0 +1,21 @@ +import { nonInheritedVariables } from "../../native-internal/root"; + +test("a second copy of the module shares the non-inheriting registry", async () => { + // The exports map splits import and require onto different builds and Metro resolves + // that per requesting module, so two copies of native-internal/root can load in one + // bundle. StyleCollection is globalThis-pinned, so whichever copy wins it does all the + // injecting and fills ITS registry. If this one were module-scoped, a rules.ts bound to + // the other copy would read an empty Set and the filter would never fire — the ring + // leaks again, with nothing to indicate why. + const firstCopy = await import("../../native-internal/root"); + firstCopy.nonInheritedVariables.add("tw-ring-shadow"); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + // The module body really re-ran, so the assertion below is about two copies + expect(secondCopy).not.toBe(firstCopy); + + expect(secondCopy.nonInheritedVariables).toBe(nonInheritedVariables); + expect(secondCopy.nonInheritedVariables.has("tw-ring-shadow")).toBe(true); +}); diff --git a/src/__tests__/native/non-inheriting-variables.test.tsx b/src/__tests__/native/non-inheriting-variables.test.tsx index 729d9899..12819e9a 100644 --- a/src/__tests__/native/non-inheriting-variables.test.tsx +++ b/src/__tests__/native/non-inheriting-variables.test.tsx @@ -133,3 +133,91 @@ test("an ancestor's ring does not reach a descendant's box-shadow", () => { boxShadow: [], }); }); + +test("a non-inheriting custom property does not reach a grandchild", () => { + // Distinguishes "withheld one level" from "withheld entirely". An implementation + // that only blanked the immediate child would pass every test above. + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .parent { --my-var: 11px; } + .other { --my-var: 20px; } + .mid { opacity: 1; } + .child { height: var(--my-var); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ height: 0 }); +}); + +test("a descendant falls through to the var() fallback when there is no initial value", () => { + // The --tw-ring-color shape: registered non-inheriting with no default. The compiler + // records it but publishes no root variable, so the descendant must reach its fallback + // rather than resolving undefined. + registerCSS(` + @property --no-init { + syntax: "*"; + inherits: false; + } + .parent { --no-init: 10px; } + .other { --no-init: 20px; } + .child { width: var(--no-init, 99px); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 99 }); +}); + +test("a descendant declaring the property itself wins over the ancestor", () => { + registerCSS(` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { --my-var: 30px; width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 30 }); +}); + +test("a name registered by an earlier test does not leak into this one", () => { + // Names the jest reset as its own subject. Without it this guarantee rests on the + // tests above happening to reuse --my-var and happening to run first. + registerCSS(` + .parent { --leaky: 10px; } + .other { --leaky: 20px; } + .child { width: var(--leaky); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index b84c4d71..548454ee 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,9 +32,19 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); -// Module-level like the families above, not a StyleCollection field: StyleCollection is -// assigned with `??=`, so a new field is missing if another copy already claimed the global -export const nonInheritedVariables = new Set(); +declare global { + var __react_native_css_non_inherited_variables: Set | undefined; +} + +// Pinned to globalThis like style-collection.ts and variables.tsx. The exports map splits +// import and require onto different builds and Metro resolves that per requesting module, +// so two copies of this file can load. StyleCollection is globalThis-pinned, so whichever +// copy wins it does all the injecting and fills ITS Set — a rules.ts bound to the other +// copy would read an empty one and the filter would silently never fire. +globalThis.__react_native_css_non_inherited_variables ??= new Set(); + +export const nonInheritedVariables = + globalThis.__react_native_css_non_inherited_variables; rootVariables("__rn-css-rem").set([[14]]); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument From 8b93370188162d1dce8f8de1964d6752932cef45 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:47:25 +0300 Subject: [PATCH 04/12] fix(native): withhold non-inheriting properties from VariableContextProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider spread its `value` straight into the VariableContext, so a property registered `inherits: false` reached every descendant — the same leak the rest of this branch closes, through the one channel that never consulted the registry. It is also a platform divergence. On web the provider renders a real `
` and the browser's own cascade withholds a non-inheriting property from the subtree, so the same input gave opposite answers per platform, on the exact rule the feature is about. `updateRules` did the filtering inline, which is how the provider came to be missed. Both now go through `assignInheritedVariables`, beside the registry: one definition of what an element publishes to its descendants, for every channel that builds a VariableContext. Three tests. Withheld, an inheriting property still published, and one name withheld from a `value` whose sibling name is not — so blanking the whole object fails. Reverting the provider to a plain spread turns the first and third red and leaves the second green. --- .../native/non-inheriting-channels.test.tsx | 397 ++++++++++++++++++ src/native-internal/root.ts | 20 + src/native-internal/variables.tsx | 22 +- src/native/react/rules.ts | 17 +- 4 files changed, 437 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/native/non-inheriting-channels.test.tsx diff --git a/src/__tests__/native/non-inheriting-channels.test.tsx b/src/__tests__/native/non-inheriting-channels.test.tsx new file mode 100644 index 00000000..184c35e8 --- /dev/null +++ b/src/__tests__/native/non-inheriting-channels.test.tsx @@ -0,0 +1,397 @@ +import { render, screen } from "@testing-library/react-native"; +import { VariableContextProvider } from "react-native-css"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { nonInheritedVariables } from "react-native-css/native-internal"; + +const parentTestID = "parent"; + +const registration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } +`; + +const inheritingRegistration = ` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } +`; + +// Most custom properties below are declared twice. A property with a single definition is +// folded into its consumers by the compile-time inliner, so a runtime assertion over one +// would be measuring the compiler. The tests that name the inliner as their subject say so + +/* ------------------------------------------------------------------ * + * Channel 1 — VariableContextProvider + * ------------------------------------------------------------------ */ + +test("VariableContextProvider does not publish a non-inheriting property", () => { + // Web renders this provider as a real
, so the browser's + // own cascade withholds a non-inheriting property from the descendant. Native has to + // reach the same answer or the two platforms disagree on the rule this feature IS + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("VariableContextProvider still publishes an inheriting property", () => { + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("VariableContextProvider withholds one property without withholding its siblings", () => { + registerCSS(` + ${registration} + @property --kept { + syntax: ""; + inherits: true; + initial-value: 0px; + } + .parent { --my-var: 1px; --kept: 1px; } + .other { --my-var: 2px; --kept: 2px; } + .child { width: var(--my-var); height: var(--kept); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + width: 0, + height: 20, + }); +}); + +/* ------------------------------------------------------------------ * + * Channel 3 — :root, and the compile-time inliner behind it + * ------------------------------------------------------------------ */ + +test(":root does not supply a non-inheriting property to a descendant", () => { + // :root declares the property on the root element. Every other element gets it by + // INHERITANCE, which is exactly what the registration switches off. The value the + // descendant must see is the registered initial value + registerCSS(` + ${registration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test(":root does not supply a non-inheriting property, whichever order it is declared in", () => { + // The two values share one rootVariables slot, so the winner is decided by source + // order. Tailwind emits @property first and :root after, which is the losing order + registerCSS(` + :root { --my-var: 50px; } + :root { --my-var: 50px; } + ${registration} + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test(":root with a single declaration does not supply a non-inheriting property", () => { + // A property declared once is folded into its consumers by the compile-time inliner, + // which resolves it before any registry exists. A runtime-only fix cannot reach this + registerCSS(` + ${registration} + :root { --my-var: 50px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("an ancestor class with a single declaration does not supply a non-inheriting property", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("a single-declaration non-inheriting property still applies to the element declaring it", () => { + // The counterpart to the test above: blocking the inliner must not cost the declaring + // element its own value, which now has to resolve at runtime instead of at compile time + registerCSS(` + ${registration} + .self { --my-var: 10px; width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test(":root still supplies an inheriting property to a descendant", () => { + registerCSS(` + ${inheritingRegistration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + +test("a :root declaration beats a registered initial value declared after it", () => { + // Both are values of the same name, but one is a DECLARATION and the other is the + // property's default. A declaration wins, wherever the @property block happens to sit + registerCSS(` + :root { --my-var: 50px; } + :root { --my-var: 50px; } + ${inheritingRegistration} + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + +test("an element declaring a non-inheriting property beats :root for itself", () => { + registerCSS(` + ${registration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + .self { --my-var: 30px; width: var(--my-var); } + .other { --my-var: 40px; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 30 }); +}); + +/* ------------------------------------------------------------------ * + * Lifecycle — re-registration replaces, it does not accumulate + * ------------------------------------------------------------------ */ + +test("re-registering a stylesheet replaces the non-inheriting registry", () => { + // Fast Refresh re-injects the whole stylesheet. Editing `inherits: false` to `true` has + // to take effect; an append-only registry pins the property non-inheriting for the rest + // of the session and only a full reload clears it + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(true); + + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(false); +}); + +test("deleting an @property rule un-registers the property", () => { + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(true); + + registerCSS(` + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + expect(nonInheritedVariables.has("my-var")).toBe(false); +}); + +test("a re-registered property inherits again in a rendered tree", () => { + registerCSS(` + ${registration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + registerCSS(` + ${inheritingRegistration} + .parent { --my-var: 10px; } + .other { --my-var: 20px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("re-registering keeps the registry object identity", () => { + // The globalThis pin exists so two copies of native-internal/root share ONE container. + // A reload replaces the container's CONTENTS; swapping the container itself would hand + // the other copy a Set nothing writes to any more + const before = nonInheritedVariables; + + registerCSS(` + ${registration} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + `); + + expect(nonInheritedVariables).toBe(before); + expect(globalThis.__react_native_css_non_inherited_variables).toBe(before); +}); + +/* ------------------------------------------------------------------ * + * Tailwind v4 ring composition, end to end + * ------------------------------------------------------------------ */ + +const tailwindRingCss = ` + @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --tw-ring-color { syntax: "*"; inherits: false; } + @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + + .ring-2 { + --tw-ring-shadow: 0 0 0 2px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-4 { + --tw-ring-shadow: 0 0 0 4px var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-red-500 { --tw-ring-color: #fb2c36; } + .ring-blue-500 { --tw-ring-color: #2c36fb; } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 #0000001a; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } +`; + +test("a shadow-none descendant of a ringed ancestor paints no ring", () => { + // The device report this whole feature comes from: on an Android handset a shadow-none + // descendant painted its ancestor's ring, because every Tailwind shadow-* utility + // composes var(--tw-ring-shadow) and that variable used to inherit + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(parentTestID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + color: "#fb2c36", + }, + ], + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); +}); + +test("a ringed descendant of a ringed ancestor paints only its own ring", () => { + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 4, + color: "#2c36fb", + }, + ], + }); +}); + +test("a ringed descendant inherits neither the ring width nor the ring colour", () => { + // ring-4 with no ring colour of its own must reach its own currentcolor fallback, NOT + // the ancestor's red. Two variables, two independent leaks, one assertion. + // currentcolor resolves to the platform's text colour, as in filters.test.tsx + registerCSS(tailwindRingCss); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 4, + color: { semantic: ["label", "labelColor"] }, + }, + ], + }); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 548454ee..64703d2b 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -46,6 +46,26 @@ globalThis.__react_native_css_non_inherited_variables ??= new Set(); export const nonInheritedVariables = globalThis.__react_native_css_non_inherited_variables; +/** + * Copy the custom properties an element publishes to its descendants. A property + * registered `inherits: false` is withheld, so the descendant resolves the registered + * initial value rather than the ancestor's. Every channel that builds a VariableContext + * goes through here — a stylesheet rule, an inline `vars()`, a VariableContextProvider — + * because the inherit flag belongs to the registration, not to the declaration that set it + */ +export function assignInheritedVariables( + target: Record, + entries: Iterable, +) { + for (const [name, value] of entries) { + if (nonInheritedVariables.has(name)) { + continue; + } + + target[name] = value; + } +} + rootVariables("__rn-css-rem").set([[14]]); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument rootVariables("__rn-css-color").set([ diff --git a/src/native-internal/variables.tsx b/src/native-internal/variables.tsx index 8a0f81e0..031d117d 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -8,6 +8,7 @@ import { import type { StyleDescriptor } from "react-native-css/compiler"; import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import { assignInheritedVariables } from "./root"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -21,16 +22,21 @@ export function VariableContextProvider( ) { const inheritedVariables = useContext(VariableContext); - const value: VariableContextValue = useMemo( - () => ({ + const value: VariableContextValue = useMemo(() => { + const published: VariableContextValue = { ...inheritedVariables, - ...Object.fromEntries( - Object.entries(props.value).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), [VAR_SYMBOL]: true, - }), - [inheritedVariables, props.value], - ); + }; + + assignInheritedVariables( + published, + Object.entries(props.value).map( + ([name, value]) => [name.replace(/^--/, ""), value] as const, + ), + ); + + return published; + }, [inheritedVariables, props.value]); return {props.children}; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index 632de3be..0c05f15e 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; import { - nonInheritedVariables, + assignInheritedVariables, StyleCollection, } from "react-native-css/native-internal"; @@ -132,19 +132,14 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { + // We're going to set a value, so we need to create a new object + if (variables === undefined || variables === inheritedVariables) { variables = { ...inheritedVariables }; } - for (const v of rule.v) { - // These are the variables published to descendants. The declaring element - // still resolves its own var() from the rule, in calculateProps - if (nonInheritedVariables.has(v[0])) { - continue; - } - - variables![v[0]] = v[1]; - } + // These are the variables published to descendants. The declaring element + // still resolves its own var() from the rule, in calculateProps + assignInheritedVariables(variables, rule.v); } if (rule.c) { From fe85c008ccd1090b72d1b4f333fb7f02136ae316 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:52:16 +0300 Subject: [PATCH 05/12] fix(native): withhold non-inheriting properties set by an inline vars() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateRules` merged the inline `vars()` objects into the published variables after the registry filter had already run, so `` handed 10 to every descendant while the same value written in CSS was withheld. The result depended on how the value arrived rather than on what it was. An inline declaration wins the cascade on the element it sits on; it does not change the property's inheritance. css-properties-values-api-1 puts the inherit flag on the REGISTRATION — "controlling whether or not the property inherits by default" — and nothing in the cascade lets a declaration override that, whatever its origin. Web agrees by construction: `vars()` there returns a plain `{"--my-var": "10"}` spread into `style`, which is a real inline custom-property declaration the browser applies the registration to. The element's own bag is untouched, so the carrier still resolves its own value; only the copy published to descendants is filtered. `assignInheritedVariables` is now generic over the value type, because a resolved inline value is not a StyleDescriptor — varResolver memoises PlatformColor objects and null back into the same bag. Four tests: withheld from a descendant, still applied to the carrier, and an inheriting and an unregistered property both still reaching the descendant. The carrier declares an unrelated variable through a className, because an element whose rules declare no variable publishes no context at all and the assertion would hold without the registry being consulted. Dropping the filter turns the first red alone. --- src/__tests__/native/vars.test.tsx | 120 +++++++++++++++++++++++++++++ src/native-internal/root.ts | 6 +- src/native/react/rules.ts | 16 +++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..65a2b399 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,123 @@ test("vars", () => { color: "blue", }); }); + +const parentTestID = "parent"; + +const registration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; + } +`; + +const inheritingRegistration = ` + @property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; + } +`; + +// Each custom property is declared twice so the compile-time inliner cannot fold it, +// which is what puts the runtime path under test. +// +// The carrier also has a className declaring an UNRELATED variable. An element whose +// rules declare no variable at all publishes no context, so its inline vars() reach no +// descendant either way and the assertion below would hold without the property registry +// having been consulted at all +const publishesVariables = ` + .has-vars { --trigger: 1px; } + .has-vars-too { --trigger: 2px; } +`; + +test("an inline vars() non-inheriting property does not reach a descendant", () => { + // css-properties-values-api-1: the inherit flag belongs to the REGISTRATION, not to the + // declaration. An inline declaration wins the cascade on the element it sits on; it + // cannot make a non-inherited property inherit. Web agrees, because vars() there is a + // plain inline custom-property declaration handed straight to the browser + registerCSS(` + ${registration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 0 }); +}); + +test("an inline vars() non-inheriting property still applies to the element carrying it", () => { + // The other half of the rule: withheld from descendants, honoured on the element itself + registerCSS(` + ${registration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .self { width: var(--my-var); } + `); + + render( + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an inline vars() inheriting property still reaches a descendant", () => { + registerCSS(` + ${inheritingRegistration} + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("an inline vars() unregistered property still reaches a descendant", () => { + registerCSS(` + ${publishesVariables} + .parent { --my-var: 1px; } + .other { --my-var: 2px; } + .child { width: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 64703d2b..8f6d5c87 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -53,9 +53,9 @@ export const nonInheritedVariables = * goes through here — a stylesheet rule, an inline `vars()`, a VariableContextProvider — * because the inherit flag belongs to the registration, not to the declaration that set it */ -export function assignInheritedVariables( - target: Record, - entries: Iterable, +export function assignInheritedVariables( + target: Record, + entries: Iterable, ) { for (const [name, value] of entries) { if (nonInheritedVariables.has(name)) { diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index 0c05f15e..0cd1268e 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -220,11 +220,16 @@ export function updateRules( rules.add(inheritedVariables); if (inlineVariables.size) { + // An inline vars() declaration wins the cascade on the element it sits on, but + // cannot make a non-inherited property inherit — the inherit flag belongs to the + // @property registration, not to the declaration. So the element's own bag keeps + // every name (it is added to `rules` below, for calculateProps) while the copy + // published to descendants goes through the same filter as a stylesheet rule variables = Object.assign( {}, variables, inheritedVariables, - ...Array.from(inlineVariables), + ...Array.from(inlineVariables, publishableVariables), { [VAR_SYMBOL]: true }, ); } @@ -261,6 +266,15 @@ export function updateRules( }; } +/** + * The subset of an inline `vars()` object that descendants inherit. + */ +function publishableVariables(inlineVariable: InlineVariable): InlineVariable { + const published: InlineVariable = { [VAR_SYMBOL]: "inline" }; + assignInheritedVariables(published, Object.entries(inlineVariable)); + return published; +} + /** * Create variations of a style rule based on the config. * Cache for reference equality. From c9e4cef99051564c80e37f521bc4c324a875fd38 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:53:33 +0300 Subject: [PATCH 06/12] fix(native): an inline vars() no longer replaces the element's own variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published variable object merged the inherited bag OVER the element's own — `Object.assign({}, variables, inheritedVariables, ...inline)` — and `variables` already contains the inherited bag beneath the element's declarations. So carrying any inline `vars()` at all, even one naming a variable nobody reads, handed every descendant the ANCESTOR's value for each name the element itself declared. Found in the expression the previous commit filters; the order is the whole fix. `inheritedVariables` still has to be listed, because `variables` is undefined when a rule reads a variable without declaring one. The test drives three levels: an ancestor at 1px, a middle element declaring 50px and carrying an unrelated inline vars(), and a child reading the name. It read the ancestor's 1px and now reads 50px. Removing the inline vars() from the middle element made the same tree resolve correctly, which is what named the merge. --- src/__tests__/native/vars.test.tsx | 23 +++++++++++++++++++++++ src/native/react/rules.ts | 6 +++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index 65a2b399..46ed287f 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -136,6 +136,29 @@ test("an inline vars() inheriting property still reaches a descendant", () => { expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); }); +test("an inline vars() does not hand an ancestor's variable to the subtree", () => { + // The published object merged the inherited bag OVER the element's own, so carrying + // any inline vars() — even one naming an unrelated variable — replaced every value + // the element declared with its ancestor's + registerCSS(` + .ancestor { --shared: 1px; } + .ancestor-too { --shared: 2px; } + .middle { --shared: 50px; } + .middle-too { --shared: 60px; } + .child { width: var(--shared); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 50 }); +}); + test("an inline vars() unregistered property still reaches a descendant", () => { registerCSS(` ${publishesVariables} diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index 0cd1268e..adb7ff42 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -225,10 +225,14 @@ export function updateRules( // @property registration, not to the declaration. So the element's own bag keeps // every name (it is added to `rules` below, for calculateProps) while the copy // published to descendants goes through the same filter as a stylesheet rule + // `variables` already carries the inherited bag under the element's own values, so + // it goes second — merging the ancestor's over it would undo every declaration the + // element made. It is undefined when a rule reads a variable without declaring one, + // which is why the inherited bag is still listed variables = Object.assign( {}, - variables, inheritedVariables, + variables, ...Array.from(inlineVariables, publishableVariables), { [VAR_SYMBOL]: true }, ); From f30ec11c02bee3d45d0f82a3024ceb2dcd23ad5a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:03:37 +0300 Subject: [PATCH 07/12] fix: separate a registered initial value from the :root declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:root { --my-var: 50px }` beside `@property --my-var { inherits: false }` reached a descendant as 50, where the spec has it resolve the registered 0px. Two independent mechanisms produced that, and closing either alone leaves the other. The runtime one is a shared slot. `@property`'s initial-value went through addRootVariable, so it and the `:root` declaration were two entries in one rootVariables list and the winner was whichever came last in the source — Tailwind emits @property first and :root after, which is the losing order. varResolver then hands rootVariables to every element, which is inheritance: exactly what the registration switches off. They are different things and now sit in different slots. A `:root` declaration is a value the root element HAS and descendants read by inheriting it; a registered initial value is what the property resolves to on an element that declares it nowhere. So `vi` joins `vr` / `vu` / `vn`, varResolver skips the rootVariables rung for a non-inheriting name, and consults the registered default last — after every declaration, because a declaration beats a property's own default. That also fixes an inheriting property whose @property block sits after its :root declaration, which previously resolved to the default. The universal rung is deliberately not skipped: `* { --x }` declares the property ON each element rather than handing it down. The compile-time one is the inliner. A custom property with exactly one declaration is folded into its consumers, which answers for every element the consumer matches — sound only while the value reaches all of them. For a non-inheriting property it reaches the declaring element and nothing below, so `:root { --my-var: 50px }` written once became a literal `width: 50` on `.child` before any registry existed. Registrations are now collected in the first pass, where the inliner runs, and those names are left to the runtime. lightningcss keeps only the last @property per name, so the first pass sees the winning declaration. Ten existing expectations move from `vr` to `vi`, same values: every @property-only case in property.test.ts. "defaults are root variables, not universal" asserted the old home and is now "neither root nor universal", plus a case pinning a :root declaration and a registered default landing in different slots. Five compiler cases: the vn census derived from the fixture rather than restated, @property without an `inherits` descriptor never reaching the registry, a non-inheriting property left to the runtime however few rules declare it, and an inheriting one still inlined. Six runtime cases across :root, a single-declaration ancestor class, both source orders, and the declaring element keeping its own value. Each production line was reverted in turn: dropping the rootVariables skip turns 4 red, dropping the registered-default rung turns 8 red including two the branch already had, and dropping the inliner exclusion turns exactly the 2 single-declaration cases red. --- src/__tests__/compiler/property.test.ts | 151 ++++++++++++++++++++---- src/compiler/compiler.ts | 17 ++- src/compiler/compiler.types.ts | 10 ++ src/compiler/inline-variables.ts | 7 +- src/compiler/stylesheet.ts | 16 +++ src/native-internal/root.ts | 11 ++ src/native-internal/style-collection.ts | 14 ++- src/native/styles/variables.ts | 17 ++- 8 files changed, 216 insertions(+), 27 deletions(-) diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index f51a6974..2c67aba1 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -10,9 +10,9 @@ test("@property with length initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.has("tw-translate-x")).toBe(true); expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); }); @@ -26,6 +26,7 @@ test("@property without initial value is skipped", () => { `); const result = compiled.stylesheet(); + expect(result.vi).toBeUndefined(); expect(result.vr).toBeUndefined(); }); @@ -39,9 +40,9 @@ test("@property with number initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-backdrop-opacity")).toStrictEqual([[1]]); }); @@ -55,9 +56,9 @@ test("@property with color initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-ring-offset-color")).toStrictEqual([["#fff"]]); }); @@ -71,13 +72,18 @@ test("@property with token-list initial value (shadow)", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-shadow")).toStrictEqual([[[0, 0, "#0000"]]]); }); -test("@property defaults are root variables, not universal", () => { +test("@property defaults are neither root nor universal variables", () => { + // A registered initial value is not a declaration on any element. :root's are values + // the root element HAS and descendants inherit, and *'s are declared on each element; + // this is what the property resolves to where nothing declares it. Sharing vr let + // source order pick between a :root declaration and the default, and left a + // non-inheriting property no way to reach its default once :root was skipped const compiled = compile(` @property --tw-shadow { syntax: "*"; @@ -87,10 +93,27 @@ test("@property defaults are root variables, not universal", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); + expect(result.vr).toBeUndefined(); expect(result.vu).toBeUndefined(); }); +test("a :root declaration and a registered default land in different slots", () => { + const compiled = compile(` +@property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; +} +:root { --my-var: 50px; } +:root { --my-var: 50px; } +`); + + const result = compiled.stylesheet(); + expect(new Map(result.vr).get("my-var")).toStrictEqual([[50]]); + expect(new Map(result.vi).get("my-var")).toStrictEqual([[0]]); +}); + test("@supports -moz-orient fallback no longer fires", () => { const compiled = compile(` @supports (-moz-orient: inline) { @@ -139,7 +162,7 @@ test("@property + class override produces valid stylesheet", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); expect(result.s).toBeDefined(); const shadowRule = result.s?.find(([name]) => name === "shadow-md"); @@ -156,9 +179,9 @@ test("@property with percentage initial value", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-shadow-alpha")).toStrictEqual([["100%"]]); }); @@ -182,9 +205,9 @@ test("multiple @property declarations with verified values", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); expect(vrMap.get("tw-translate-y")).toStrictEqual([[0]]); expect(vrMap.get("tw-rotate")).toStrictEqual([["0deg"]]); @@ -200,9 +223,9 @@ test("@property with repeated single-child unwraps to scalar", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); // Single-child repeated (+ with one value) should unwrap // to the same shape as a direct type expect(vrMap.get("my-offset")).toStrictEqual([[10]]); @@ -222,8 +245,92 @@ test("@property inherits: false is recorded, initial value or not", () => { `); const result = compiled.stylesheet(); - // --tw-ring-color has no initial value, so it publishes no root variable + // --tw-ring-color has no initial value, so it publishes no registered default expect(result.vn).toStrictEqual(["tw-ring-shadow", "tw-ring-color"]); + expect(new Map(result.vi).has("tw-ring-color")).toBe(false); +}); + +test("vn carries exactly the properties declared inherits: false", () => { + // Derived rather than restated: the expected set is read off the source CSS, so a + // property added to the fixture is covered without touching the assertion + const declarations: [name: string, inherits: boolean][] = [ + ["--a-off", false], + ["--b-on", true], + ["--c-off", false], + ["--d-on", true], + ]; + + const compiled = compile( + declarations + .map( + ([name, inherits]) => + `@property ${name} { syntax: ""; inherits: ${inherits}; initial-value: 0px; }`, + ) + .join("\n"), + ); + + expect(declarations.length).toBeGreaterThan(0); + expect([...(compiled.stylesheet().vn ?? [])].sort()).toStrictEqual( + declarations + .filter(([, inherits]) => !inherits) + .map(([name]) => name.slice(2)) + .sort(), + ); +}); + +test("@property without an inherits descriptor never reaches the registry", () => { + // syntax and inherits are both required; a rule missing either is invalid + // (css-properties-values-api-1). The spec has the invalid rule ignored, while + // lightningcss rejects the whole sheet — either way no half-registration exists for + // the inherit flag to be guessed from, which is the property this pins + expect(() => + compile(` +@property --no-descriptor { + syntax: ""; + initial-value: 0px; +} +`), + ).toThrow("Invalid @ rule body"); +}); + +test("a non-inheriting property is left to the runtime, however few rules declare it", () => { + // The inliner folds a property with one declaration into its consumers, which answers + // for every element the consumer matches. That is sound only while the value reaches + // all of them, and a non-inheriting property reaches the declaring element alone + const compiled = compile(` +@property --pinned { + syntax: ""; + inherits: false; + initial-value: 0px; +} +.parent { --pinned: 10px; } +.child { width: var(--pinned); } +`); + + const result = compiled.stylesheet(); + const child = result.s?.find(([name]) => name === "child"); + + // The declaration survives as a rule, and the consumer still holds a var() call + expect(result.s?.find(([name]) => name === "parent")).toBeDefined(); + expect(JSON.stringify(child)).toContain('"var"'); +}); + +test("an inheriting property with one declaration is still inlined", () => { + const compiled = compile(` +@property --folded { + syntax: ""; + inherits: true; + initial-value: 0px; +} +.parent { --folded: 10px; } +.child { width: var(--folded); } +`); + + const result = compiled.stylesheet(); + const child = result.s?.find(([name]) => name === "child"); + + expect(JSON.stringify(child)).not.toContain('"var"'); + expect(JSON.stringify(child)).toContain("10"); }); test("@property inherits: true is not recorded", () => { @@ -289,7 +396,7 @@ test("the last @property declaration of a name decides inherits", () => { test("@property inside @media is not recorded — a known limitation", () => { // lightningcss reports the nested rule as type "unknown" and extractRule drops it, - // so neither vn nor vr is emitted. Pinned in both directions: the day extractRule + // so neither vn nor vi is emitted. Pinned in both directions: the day extractRule // learns about a nested @property, vn has to follow it. const compiled = compile(` @media (min-width: 100px) { @@ -303,7 +410,7 @@ test("@property inside @media is not recorded — a known limitation", () => { const result = compiled.stylesheet(); expect(result.vn).toBeUndefined(); - expect(result.vr).toBeUndefined(); + expect(result.vi).toBeUndefined(); }); test("@property with repeated multi-child preserves array", () => { @@ -316,9 +423,9 @@ test("@property with repeated multi-child preserves array", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vi).toBeDefined(); - const vrMap = new Map(result.vr); + const vrMap = new Map(result.vi); // Multi-child repeated (+ with two values) keeps the array form expect(vrMap.get("my-offsets")).toStrictEqual([[[10, 20]]]); }); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 13ad4ae6..b6b8df8e 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -114,6 +114,19 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { if (options.inlineVariables !== false) { const exclusionList: string[] = options.inlineVariables?.exclude ?? []; + // The inliner runs in this pass, so the registrations it has to respect are collected + // here rather than read off the builder, which the second pass fills. lightningcss + // keeps only the last @property rule per name, so this sees the winning declaration + const nonInheritedVariables = new Set(); + + firstPassVisitor.Rule = (rule) => { + if (rule.type === "property" && !rule.value.inherits) { + nonInheritedVariables.add(rule.value.name); + } + + return rule; + }; + firstPassVisitor.Declaration = (decl) => { if ( decl.property === "custom" && @@ -132,7 +145,7 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } }; firstPassVisitor.StyleSheetExit = (sheet) => { - return inlineVariables(sheet, vars); + return inlineVariables(sheet, vars, nonInheritedVariables); }; } @@ -421,7 +434,7 @@ function extractPropertyRule( const value = parsePropertyInitialValue(initialValue, builder); if (value !== undefined) { - builder.addRootVariable(varName, value); + builder.addRegisteredInitialValue(varName, value); } } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 79c57c18..f6c02824 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -42,6 +42,16 @@ export interface ReactNativeCssStyleSheet { vu?: RootVariables; /** Non-Inheriting Variables */ vn?: string[]; + /** + * Registered Initial Values — the `initial-value` of an `@property` rule. + * + * Not a declaration on any element, which is why it is not in `vr`: a `:root` + * declaration is a value the root element HAS and descendants inherit, while this is + * the value a property TAKES on an element that declares it nowhere. Sharing one slot + * let source order decide between them, and left a non-inheriting property no way to + * reach its default once `:root` was skipped + */ + vi?: RootVariables; } /******************************** Styles ********************************/ diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..5b68f84a 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -10,9 +10,14 @@ import type { UniqueVarInfo } from "./compiler.types"; export function inlineVariables( stylesheet: StyleSheet, vars: Map, + nonInheritedVariables: ReadonlySet, ) { for (const [name, info] of [...vars]) { - if (info.count !== 1) { + // Folding a single declaration into its consumers answers for every element the + // consumer matches, which is sound only while the value reaches all of them. A + // property registered `inherits: false` reaches the declaring element and nothing + // below it, so a consumer in another rule must resolve it at runtime instead + if (info.count !== 1 || nonInheritedVariables.has(name)) { vars.delete(name); } else { flattenVar(name, vars); diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 25018057..9487aa79 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -64,6 +64,7 @@ export class StylesheetBuilder { rootVariables?: VariableRecord; universalVariables?: VariableRecord; nonInheritedVariables?: Set; + registeredInitialValues?: VariableRecord; animations?: AnimationRecord; rem: number; ruleOrder: number; @@ -178,6 +179,12 @@ export class StylesheetBuilder { stylesheetOptions.vn = [...this.shared.nonInheritedVariables]; } + if (this.shared.registeredInitialValues) { + stylesheetOptions.vi = Object.entries( + this.shared.registeredInitialValues, + ).map(([key, value]) => [key, value] as const); + } + if (this.shared.animations) { stylesheetOptions.k = Object.entries(this.shared.animations); } @@ -593,6 +600,15 @@ export class StylesheetBuilder { this.shared.nonInheritedVariables.add(name); } + /** + * A registration carries exactly one initial value, so this assigns where + * addRootVariable pushes — there is no list of candidates to pick from. + */ + addRegisteredInitialValue(name: string, value: StyleDescriptor) { + this.shared.registeredInitialValues ??= {}; + this.shared.registeredInitialValues[name] = [[value]]; + } + newAnimationFrames(name: string) { this.shared.animations ??= {}; diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 8f6d5c87..084888a0 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,6 +32,17 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); +/** + * The `initial-value` of an `@property` rule: what a custom property resolves to on an + * element that declares it nowhere. Separate from rootVariables because a `:root` + * declaration is a value the root element HAS and descendants read by inheritance, which + * is the one thing a non-inheriting property never does. + * + * A registration carries a single value, so each entry holds one — the family shape is + * shared with the other two so a re-injected stylesheet notifies its readers. + */ +export const registeredInitialValues = rootVariableFamily(); + declare global { var __react_native_css_non_inherited_variables: Set | undefined; } diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index c5523453..5864a237 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -16,11 +16,17 @@ import { } from "../native/reactivity"; import { nonInheritedVariables, + registeredInitialValues, rootVariables, universalVariables, } from "./root"; -export { nonInheritedVariables, rootVariables, universalVariables }; +export { + nonInheritedVariables, + registeredInitialValues, + rootVariables, + universalVariables, +}; interface StyleCollectionType { styles: ReturnType>>; @@ -99,6 +105,12 @@ globalThis.__react_native_css_style_collection ??= { } } + if (options.vi) { + for (const entry of options.vi) { + registeredInitialValues(entry[0]).set(entry[1]); + } + } + if (options.vn) { for (const name of options.vn) { nonInheritedVariables.add(name); diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index af3d6ee2..1e99bd00 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -1,5 +1,7 @@ import type { StyleDescriptor, StyleFunction } from "react-native-css/compiler"; import { + nonInheritedVariables, + registeredInitialValues, rootVariables, universalVariables, } from "react-native-css/native-internal"; @@ -77,7 +79,20 @@ export function varResolver( return value; } - value = resolve(get(rootVariables(name))); + // :root declares the property on the root element and every other element reads it by + // inheritance, so a registration that switches inheritance off skips this rung. The + // universal rung above stays: `* { --x }` declares the property ON each element + if (!nonInheritedVariables.has(name)) { + value = resolve(get(rootVariables(name))); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + } + } + + // Last, because a declaration anywhere above beats the property's own default + value = resolve(get(registeredInitialValues(name))); if (value !== undefined) { options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; options.inlineVariables[name] = value; From 3abd4818bf49daa350cf8a03aa1490d4ddc45f8f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:04:47 +0300 Subject: [PATCH 08/12] fix(native): a stylesheet reload replaces the non-inheriting registry `inject` only ever added to it, so the registry was append-only across reloads. A Fast Refresh that edits an @property rule to `inherits: true`, or deletes it, left the property pinned non-inheriting for the rest of the session, and only a full reload could clear it. Editing the descriptor is the one change this feature makes worth making, and it was the one change that did not take. The container is cleared rather than replaced. The globalThis pin exists so a second copy of root.ts shares this exact Set, and handing that copy a Set nothing writes to any more is the original dual-package bug in a new shape. A test pins the identity alongside the contents. The jest reset stays: inject only replaces the registry when it runs, so a test that never calls registerCSS still needs it. Three tests: `inherits: false` re-registered as true, the @property rule deleted outright, and a rendered tree inheriting again after the second registration. Removing the clear turns exactly those three red. --- src/jest/index.ts | 2 +- src/native-internal/style-collection.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jest/index.ts b/src/jest/index.ts index bc33841d..3e81bb76 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -21,7 +21,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); - // inject accumulates, so names stay registered across tests + // inject replaces the registry, so this covers the tests that never call registerCSS nonInheritedVariables.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme(null); diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index 5864a237..0c6d63cc 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -111,6 +111,12 @@ globalThis.__react_native_css_style_collection ??= { } } + // A stylesheet reload REPLACES the registrations it carries — editing an @property + // rule to `inherits: true`, or deleting it, has to take effect. The container itself + // is kept, because the globalThis pin exists so a second copy of root.ts shares this + // exact Set; swapping it would leave that copy reading one nothing writes to + nonInheritedVariables.clear(); + if (options.vn) { for (const name of options.vn) { nonInheritedVariables.add(name); From 5c4745baa55d7161eff59eaed3b497acbeb3356e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:08:02 +0300 Subject: [PATCH 09/12] fix(native): inject universal variables into the registry that reads them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inject` put `options.vu` into rootVariables, so universalVariables was written by nothing and the rung varResolver reads for it always returned undefined. Both kinds landed in one slot, with `vu` injected after `vr` and overwriting it, which is why the dead rung never showed. Skipping rootVariables for a non-inheriting name turns that into a wrong answer: `* { --my-var: 5px }` resolved 5 through the root slot and now resolves the registered 0px, when `*` matches each element in its own right — the element DECLARES the property and the registration is not involved. A browser gives 5. Sending `vu` to universalVariables restores it through the rung that models what the selector means. Precedence is unchanged for a name declared in both: `*` won before because it was injected last into the shared slot, and wins now because varResolver reads universal before root — which is also the right order, a declaration on the element beating a value inherited from the root. Three tests: a non-inheriting property reaching an element and a grandchild through `*`, and `*` beating `:root` for one name. Putting `vu` back into rootVariables turns the first two red. --- .../native/non-inheriting-channels.test.tsx | 52 +++++++++++++++++++ src/native-internal/style-collection.ts | 4 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/non-inheriting-channels.test.tsx b/src/__tests__/native/non-inheriting-channels.test.tsx index 184c35e8..6955ce24 100644 --- a/src/__tests__/native/non-inheriting-channels.test.tsx +++ b/src/__tests__/native/non-inheriting-channels.test.tsx @@ -212,6 +212,58 @@ test("an element declaring a non-inheriting property beats :root for itself", () expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 30 }); }); +/* ------------------------------------------------------------------ * + * The universal selector declares, it does not hand down + * ------------------------------------------------------------------ */ + +test("* supplies a non-inheriting property to every element", () => { + // `*` matches each element in its own right, so each one DECLARES the property and + // the registration never comes into it. This is the rung :root is skipped for + registerCSS(` + ${registration} + * { --my-var: 5px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + +test("* supplies a non-inheriting property at every depth", () => { + registerCSS(` + ${registration} + * { --my-var: 5px; } + .parent { opacity: 1; } + .child { width: var(--my-var); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + +test("* beats :root for the same name", () => { + // A declaration on the element beats a value inherited from the root + registerCSS(` + ${inheritingRegistration} + :root { --my-var: 50px; } + :root { --my-var: 50px; } + * { --my-var: 5px; } + .child { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); +}); + /* ------------------------------------------------------------------ * * Lifecycle — re-registration replaces, it does not accumulate * ------------------------------------------------------------------ */ diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index 0c6d63cc..a4f55975 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -99,9 +99,11 @@ globalThis.__react_native_css_style_collection ??= { } } + // `* { --x }` declares the property ON each element, which is the rung varResolver + // reads before rootVariables and the one a registration cannot switch off if (options.vu) { for (const entry of options.vu) { - rootVariables(entry[0]).set(entry[1]); + universalVariables(entry[0]).set(entry[1]); } } From 705cdeaf7e43d18e577be972500c262a9d06af2a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:13:52 +0300 Subject: [PATCH 10/12] test: reset every variable registry between tests, not just the registry of names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jest beforeEach cleared StyleCollection.styles and the non-inheriting names, but the variable families themselves carried over. A stylesheet reload only overwrites the names the new sheet mentions, so a name it drops keeps whatever the previous sheet gave it — right for a running app, wrong between two tests. Nothing had exercised it because no test read a name a previous test had written through :root or `*`. Adding the universal-selector cases did: run the suite with --randomize and `* { --my-var: 5px }` reaches three tests that never declared it, turning them red on some orders and green on others. root.ts owns the registries and the two seeds it writes at import, so it owns the reset. rootVariables could not simply be cleared — dropping __rn-css-rem and __rn-css-color would cost every later test its rem and currentcolor — so the seeding is now a function the reset calls back. Verified with --randomize over the whole suite three times: 1095 passing, order independent. Reverting the beforeEach to clearing names alone turns the same three red under seed 1234. --- src/jest/index.ts | 5 ++--- src/native-internal/root.ts | 40 +++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/jest/index.ts b/src/jest/index.ts index 3e81bb76..547d9d8d 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,7 +4,7 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { nonInheritedVariables } from "react-native-css/native-internal"; +import { resetVariableRegistries } from "react-native-css/native-internal"; import { colorScheme, dimensions } from "../native/reactivity"; @@ -21,8 +21,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); - // inject replaces the registry, so this covers the tests that never call registerCSS - nonInheritedVariables.clear(); + resetVariableRegistries(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme(null); colorScheme.set(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 084888a0..a7b1d564 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -77,13 +77,33 @@ export function assignInheritedVariables( } } -rootVariables("__rn-css-rem").set([[14]]); -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -rootVariables("__rn-css-color").set([ - [ - Platform.OS === "ios" - ? PlatformColor("label", "labelColor") - : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -] as any); +function seedRootVariables() { + rootVariables("__rn-css-rem").set([[14]]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + rootVariables("__rn-css-color").set([ + [ + Platform.OS === "ios" + ? PlatformColor("label", "labelColor") + : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); +} + +seedRootVariables(); + +/** + * Return every variable registry to its boot state, seeds included. + * + * A stylesheet reload only overwrites the names the new sheet mentions, so a name it + * drops keeps the value the previous one gave it. That is what a reload should do to a + * running app and the opposite of what one test should do to the next. + */ +export function resetVariableRegistries() { + rootVariables.clear(); + universalVariables.clear(); + registeredInitialValues.clear(); + nonInheritedVariables.clear(); + + seedRootVariables(); +} From 304a5064710835eb525ea1275d4915144788102b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 19:58:02 +0300 Subject: [PATCH 11/12] fix(native): pin the registered initial values to globalThis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `registeredInitialValues` is the one variable store in this file a second copy of the module cannot reach. The Set beside it is already pinned, and StyleCollection — which does all the injecting — is pinned too, so under a dual package split the copy that loses the race answers `undefined` for every `@property` initial value. That is not a lost fallback. Tailwind composes a registered width into arithmetic on the element that DECLARES it — `calc(2px + var(--tw-ring-offset-width))` — so the copy corrupts a length that element computes for itself, with no ancestor involved. Two tests, both mutation-proven against a real second copy: `jest.resetModules()` re-evaluates the module against the same globalThis, which is the dual package topology exactly. The second drives the real compiler and `StyleCollection.inject`, so it measures the injected value rather than a hand-written one. --- .../native/non-inheriting-registry.test.ts | 62 ++++++++++++++++--- src/native-internal/root.ts | 36 +++++++---- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/__tests__/native/non-inheriting-registry.test.ts b/src/__tests__/native/non-inheriting-registry.test.ts index b079630a..9b3dc0fc 100644 --- a/src/__tests__/native/non-inheriting-registry.test.ts +++ b/src/__tests__/native/non-inheriting-registry.test.ts @@ -1,12 +1,19 @@ -import { nonInheritedVariables } from "../../native-internal/root"; +import { registerCSS } from "react-native-css/jest"; + +import { + nonInheritedVariables, + registeredInitialValues, +} from "../../native-internal/root"; + +// jest.resetModules() gives a fresh module registry against the same globalThis, which is +// exactly the dual package case: the exports map splits import and require onto different +// builds and Metro resolves that per requesting module, so two copies of +// native-internal/root evaluate in one bundle. StyleCollection is globalThis-pinned, so +// whichever copy wins does all the injecting and fills ITS registries test("a second copy of the module shares the non-inheriting registry", async () => { - // The exports map splits import and require onto different builds and Metro resolves - // that per requesting module, so two copies of native-internal/root can load in one - // bundle. StyleCollection is globalThis-pinned, so whichever copy wins it does all the - // injecting and fills ITS registry. If this one were module-scoped, a rules.ts bound to - // the other copy would read an empty Set and the filter would never fire — the ring - // leaks again, with nothing to indicate why. + // If this one were module-scoped, a rules.ts bound to the other copy would read an empty + // Set and the filter would never fire — the ring leaks again, with nothing to indicate why const firstCopy = await import("../../native-internal/root"); firstCopy.nonInheritedVariables.add("tw-ring-shadow"); @@ -19,3 +26,44 @@ test("a second copy of the module shares the non-inheriting registry", async () expect(secondCopy.nonInheritedVariables).toBe(nonInheritedVariables); expect(secondCopy.nonInheritedVariables.has("tw-ring-shadow")).toBe(true); }); + +test("a second copy of the module shares the registered initial values", async () => { + const firstCopy = await import("../../native-internal/root"); + firstCopy.registeredInitialValues("tw-ring-offset-width").set([[0]]); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + expect(secondCopy).not.toBe(firstCopy); + + expect(secondCopy.registeredInitialValues).toBe(registeredInitialValues); + expect(secondCopy.registeredInitialValues("tw-ring-offset-width").get()).toBe( + 0, + ); +}); + +test("an @property initial value injected through one copy resolves in the other", async () => { + // The registered initial value is not a fallback the resolver can do without. Tailwind + // composes `--tw-ring-offset-width` into a length — `calc(2px + var(--tw-ring-offset-width))` + // — ON THE ELEMENT THAT DECLARES THE RING, so a copy reading an empty registry does not + // lose an inherited value it was never entitled to, it corrupts an arithmetic result the + // declaring element computes for itself + registerCSS(` + @property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; + } + .ring { width: calc(2px + var(--tw-ring-offset-width)); } + .offset { --tw-ring-offset-width: 4px; } + `); + + expect(registeredInitialValues("tw-ring-offset-width").get()).toBe(0); + + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); + + expect(secondCopy.registeredInitialValues("tw-ring-offset-width").get()).toBe( + 0, + ); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index a7b1d564..84c6180c 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,6 +32,23 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); +declare global { + var __react_native_css_registered_initial_values: + | ReturnType + | undefined; + var __react_native_css_non_inherited_variables: Set | undefined; +} + +// Both pinned to globalThis like style-collection.ts and variables.tsx. The exports map +// splits import and require onto different builds and Metro resolves that per requesting +// module, so two copies of this file can load. StyleCollection is globalThis-pinned, so +// whichever copy wins it does all the injecting and fills ITS containers — the other copy +// reads a Set whose filter never fires, and a registry that answers undefined for every +// registration. Neither has a seed to protect, so the plain `??=` is the whole guard. +globalThis.__react_native_css_registered_initial_values ??= + rootVariableFamily(); +globalThis.__react_native_css_non_inherited_variables ??= new Set(); + /** * The `initial-value` of an `@property` rule: what a custom property resolves to on an * element that declares it nowhere. Separate from rootVariables because a `:root` @@ -40,19 +57,14 @@ export const universalVariables = rootVariableFamily(); * * A registration carries a single value, so each entry holds one — the family shape is * shared with the other two so a re-injected stylesheet notifies its readers. + * + * Losing this across a copy is not a missing fallback. Tailwind composes a registered + * width into arithmetic on the element that DECLARES it — `calc(2px + + * var(--tw-ring-offset-width))` — so an empty registry corrupts a length the declaring + * element computes for itself, with no ancestor involved. */ -export const registeredInitialValues = rootVariableFamily(); - -declare global { - var __react_native_css_non_inherited_variables: Set | undefined; -} - -// Pinned to globalThis like style-collection.ts and variables.tsx. The exports map splits -// import and require onto different builds and Metro resolves that per requesting module, -// so two copies of this file can load. StyleCollection is globalThis-pinned, so whichever -// copy wins it does all the injecting and fills ITS Set — a rules.ts bound to the other -// copy would read an empty one and the filter would silently never fire. -globalThis.__react_native_css_non_inherited_variables ??= new Set(); +export const registeredInitialValues = + globalThis.__react_native_css_registered_initial_values; export const nonInheritedVariables = globalThis.__react_native_css_non_inherited_variables; From adc1691208183c2aecd3cc7740efec844cc5ddc7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:40:40 +0300 Subject: [PATCH 12/12] fix(native): a stylesheet reload retracts a registered initial value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an `@property` rule un-registered half of it. `inject` clears `nonInheritedVariables` before re-adding, so the name stopped being non-inheriting, but nothing cleared `registeredInitialValues` before the `options.vi` loop. The initial value outlived the rule that declared it, and the two halves of one registration disagreed for the rest of the session. Measured on one sheet, `.probe { width: calc(2px + var(--my-var)) }` against an `initial-value: 3px` registration: cold, no @property rule style {} @property present, then deleted style { width: 5 } Retracting is not a `clear()`. `family.clear()` is a `Map.clear()` and notifies nobody: a mounted element keeps painting the deleted value, and the next registration of that name lands on a fresh observable that element never subscribed to. Measured — with `clear()` in `inject` the registry reads `undefined` while the element still paints `width: 5`, so the registry-level assertion alone would bless it. `set(undefined)` takes the same notification path a changed value takes and leaves the observable its readers hold in place. That needs the names the previous sheet registered, so `family` gains `keys()`. Its Map is already that record; a second registry alongside it would be the same two-halves-disagreeing shape as the defect. The observable's argument widens to accept `undefined`, which the read function's first line already implements. `resetVariableRegistries` keeps clearing, because nothing is mounted across the test boundary it serves and it has no reader to strand. Its clear of `registeredInitialValues` was unguarded — removing the line left all 1121 tests green — and now turns exactly one red. Correcting the record from 304a506: `root.ts` holds four variable stores and a second copy of the module cannot reach three of them. That commit pins one, `registeredInitialValues`. `rootVariables` and `universalVariables` are pre-existing and are PR 410's subject; `nonInheritedVariables`, the other store this branch adds, was already pinned. Five tests. Four are runtime, because only a mounted reader separates a retraction that notifies from one that does not: the cold-load control, the registry retraction, the mounted element, and an observable identity that fails the moment the retraction becomes a `clear()`. The fifth pins the sheet a deleted rule compiles to, which is what the runtime retracts against. --- src/__tests__/compiler/property.test.ts | 3 + .../native/non-inheriting-channels.test.tsx | 104 +++++++++++++++++- src/native-internal/root.ts | 56 +++++++++- src/native-internal/style-collection.ts | 19 ++-- src/native/reactivity.ts | 3 + 5 files changed, 168 insertions(+), 17 deletions(-) diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 2c67aba1..6bafd490 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -359,6 +359,9 @@ test("an unregistered custom property is not recorded", () => { const result = compiled.stylesheet(); expect(result.s).toBeDefined(); expect(result.vn).toBeUndefined(); + // Neither half of a registration is emitted. This is the sheet a Fast Refresh produces + // when an @property rule is deleted, so it is what the runtime has to retract AGAINST + expect(result.vi).toBeUndefined(); }); test("@property records a name once, however many rules declare it", () => { diff --git a/src/__tests__/native/non-inheriting-channels.test.tsx b/src/__tests__/native/non-inheriting-channels.test.tsx index 6955ce24..60fbab9c 100644 --- a/src/__tests__/native/non-inheriting-channels.test.tsx +++ b/src/__tests__/native/non-inheriting-channels.test.tsx @@ -1,8 +1,12 @@ -import { render, screen } from "@testing-library/react-native"; +import { act, render, screen } from "@testing-library/react-native"; import { VariableContextProvider } from "react-native-css"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; -import { nonInheritedVariables } from "react-native-css/native-internal"; +import { + nonInheritedVariables, + registeredInitialValues, + resetVariableRegistries, +} from "react-native-css/native-internal"; const parentTestID = "parent"; @@ -342,6 +346,102 @@ test("re-registering keeps the registry object identity", () => { expect(globalThis.__react_native_css_non_inherited_variables).toBe(before); }); +/* ------------------------------------------------------------------ * + * Lifecycle — a reload retracts a registered initial value + * ------------------------------------------------------------------ */ + +// Composed into arithmetic ON THE DECLARING ELEMENT, which is how Tailwind reads +// `--tw-ring-offset-width`: `calc(2px + var(--tw-ring-offset-width))`. A registration that +// outlives the rule declaring it does not hand a descendant something it should not have +// inherited, it corrupts a length an element computes for itself +const initialValueRegistration = ` + @property --my-var { + syntax: ""; + inherits: false; + initial-value: 3px; + } +`; + +const initialValueConsumer = ` + .probe { width: calc(2px + var(--my-var)); } + .a { --my-var: 10px; } + .b { --my-var: 20px; } +`; + +test("a sheet registering nothing leaves the consumer no width", () => { + // The control for the two tests below. `.probe` reads a property no rule it matches + // declares and no @property registers, so the whole declaration drops + registerCSS(initialValueConsumer); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("deleting an @property rule retracts its initial value", () => { + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + expect(registeredInitialValues("my-var").get()).toBe(3); + + registerCSS(initialValueConsumer); + + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + +test("a mounted element drops a retracted initial value", () => { + // Deleting an @property rule un-registered only half of it: the name left + // `nonInheritedVariables` and the initial value stayed, so the two halves of one + // registration disagreed and the element kept painting a width the sheet no longer + // declares anywhere + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 5 }); + + act(() => { + registerCSS(initialValueConsumer); + }); + + // Exactly what the same sheet paints when it is the first one loaded, two tests above + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("retracting an initial value keeps the observable its readers hold", () => { + // This is why the retraction is `.set(undefined)` and not `.clear()`. Clearing the family + // drops the map entry without notifying anyone, so a mounted reader keeps the deleted + // value AND the next registration of the same name lands on an observable it never + // subscribed to — the reader is then stranded for the rest of the session + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + const before = registeredInitialValues("my-var"); + + registerCSS(initialValueConsumer); + + expect(registeredInitialValues("my-var")).toBe(before); +}); + +test("resetVariableRegistries retracts a registered initial value", () => { + // The jest preset's beforeEach is all that stands between one test's @property + // registration and the next test's. A plain clear() is right HERE and wrong in inject(): + // testing-library unmounts between tests, so this retraction has no reader to strand + registerCSS(` + ${initialValueRegistration} + ${initialValueConsumer} + `); + expect(registeredInitialValues("my-var").get()).toBe(3); + + resetVariableRegistries(); + + expect(registeredInitialValues("my-var").get()).toBeUndefined(); +}); + /* ------------------------------------------------------------------ * * Tailwind v4 ring composition, end to end * ------------------------------------------------------------------ */ diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 84c6180c..ca85d5d5 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -1,13 +1,21 @@ import { Platform, PlatformColor } from "react-native"; -import type { StyleDescriptor, VariableValue } from "react-native-css/compiler"; +import type { + RootVariables, + StyleDescriptor, + VariableValue, +} from "react-native-css/compiler"; import { testMediaQuery } from "../native/conditions/media-query"; import { family, observable, type Observable } from "../native/reactivity"; +// The argument is nullable because a reload has to be able to RETRACT a name, and the read +// below already answers `undefined` for one — see replaceRegisteredInitialValues +type VariableArg = VariableValue[] | undefined; + const rootVariableFamily = () => { - return family>(() => { - const obs = observable( + return family>(() => { + const obs = observable( (read, variableValue) => { if (!variableValue) return undefined; @@ -89,6 +97,37 @@ export function assignInheritedVariables( } } +/** + * Replace every registered initial value with the ones a stylesheet carries. + * + * A reload has to be able to DELETE an `@property` rule, and this registry is observable, + * so dropping the entry is not enough. `family.clear()` is a `Map.clear()`, which notifies + * nobody: a mounted element keeps painting the deleted registration's value, and the next + * registration of that name lands on a fresh observable that element never subscribed to. + * Retracting through `set(undefined)` takes the same notification path a changed value + * takes, and leaves the observable its readers already hold in place. + * + * `resetVariableRegistries` below still clears, because nothing is mounted across the test + * boundary it serves — the mechanism differs where the readers do. + */ +export function replaceRegisteredInitialValues(entries: RootVariables = []) { + const registered = new Set(entries.map(([name]) => name)); + + // Snapshotted because the family creates an entry for every name the resolver LOOKS UP, + // so a retraction outside a batch can notify a reader that resolves a new one mid-walk. + // Retracting a name that carries no registration is already a no-op: the observable + // recomputes to the `undefined` it holds, compares equal, and notifies nobody + for (const name of Array.from(registeredInitialValues.keys())) { + if (!registered.has(name)) { + registeredInitialValues(name).set(undefined); + } + } + + for (const [name, value] of entries) { + registeredInitialValues(name).set(value); + } +} + function seedRootVariables() { rootVariables("__rn-css-rem").set([[14]]); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument @@ -107,9 +146,14 @@ seedRootVariables(); /** * Return every variable registry to its boot state, seeds included. * - * A stylesheet reload only overwrites the names the new sheet mentions, so a name it - * drops keeps the value the previous one gave it. That is what a reload should do to a - * running app and the opposite of what one test should do to the next. + * A reload replaces the two registries an `@property` rule writes, but `:root` and `*` + * declarations are overwrite-only — a name the new sheet drops keeps the value the previous + * one gave it. That is what a reload should do to a running app and the opposite of what one + * test should do to the next, and a test that injects no stylesheet at all needs the reset + * either way. + * + * Clearing is enough here, where `inject` has to retract through `set(undefined)`: nothing + * is mounted across the boundary this serves, so there is no reader to strand. */ export function resetVariableRegistries() { rootVariables.clear(); diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index a4f55975..efd8aadf 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -17,6 +17,7 @@ import { import { nonInheritedVariables, registeredInitialValues, + replaceRegisteredInitialValues, rootVariables, universalVariables, } from "./root"; @@ -107,16 +108,16 @@ globalThis.__react_native_css_style_collection ??= { } } - if (options.vi) { - for (const entry of options.vi) { - registeredInitialValues(entry[0]).set(entry[1]); - } - } - // A stylesheet reload REPLACES the registrations it carries — editing an @property - // rule to `inherits: true`, or deleting it, has to take effect. The container itself - // is kept, because the globalThis pin exists so a second copy of root.ts shares this - // exact Set; swapping it would leave that copy reading one nothing writes to + // rule to `inherits: true`, or deleting it, has to take effect. Both halves of a + // registration are replaced, or the two disagree: the name leaves the Set below while + // its initial value stays behind, and an element goes on painting a length no rule in + // the sheet declares. Retracting an observed value is not a clear() — see root.ts + replaceRegisteredInitialValues(options.vi); + + // The container itself is kept, because the globalThis pin exists so a second copy of + // root.ts shares this exact Set; swapping it would leave that copy reading one nothing + // writes to nonInheritedVariables.clear(); if (options.vn) { diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..c76552b8 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -140,6 +140,9 @@ export function family( delete(key: Key) { return map.delete(key); }, + keys() { + return map.keys(); + }, clear() { return map.clear(); },