From 61305fe2de10496c4d02bb670ad2c7b75be9ec45 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:47:59 +0300 Subject: [PATCH 1/5] fix(native): stop a read from swallowing an observable notification `observable()` used `value` for two jobs: the cache a reader gets back, and the yardstick `run()`/`set()` compare against to decide whether observers still need telling. `get()` refreshes the cache, so a read that landed between a write and its notification moved the yardstick onto the new value and the change was then treated as already delivered - the subscriber was never notified. Two interleavings reach this through the public API with no timing involved. Inside a batch, a write queues the derived observable's effect and any read before the flush advances the cache, so the flush finds them equal and returns. Unbatched, a co-observer registered on the source ahead of the derived observable's own effect reads the derived value during the fan-out with the same result. Track the published value separately: only `notify()` advances it, so a read can no longer cancel a notification. `get()` publishes when nobody is subscribed, since no observer can be owed a value produced while the observer set was empty - without that, the first dependency change would notify for a value the subscriber had already read. `set()` collapses to a single guard. The static branch's comparison was provably identical to the dynamic one (a static observable has `didInit` from construction, so `get()` never refreshes its cache and the two values cannot diverge), and two guards meant one of them could never be made to fail. The test drives every gap a read can land in around a write, batched and unbatched, over a write sequence that returns to an already-seen value and includes a write that changes nothing. It asserts both halves: a settled write leaves no subscriber stale, and no read manufactures a notification. --- src/__tests__/native/reactivity.test.ts | 257 ++++++++++++++++++++++++ src/native/reactivity.ts | 43 +++- 2 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/native/reactivity.test.ts diff --git a/src/__tests__/native/reactivity.test.ts b/src/__tests__/native/reactivity.test.ts new file mode 100644 index 00000000..e32d651c --- /dev/null +++ b/src/__tests__/native/reactivity.test.ts @@ -0,0 +1,257 @@ +import { + observable, + observableBatch, + type Effect, + type Observable, +} from "../../native/reactivity"; + +/** + * `observableBatch` is module state. A scenario that throws mid-batch would + * otherwise leak an open batch into the next test, so every test starts from a + * closed batch regardless of how the previous one ended. + */ +beforeEach(() => { + observableBatch.current = undefined; +}); + +interface Spy extends Effect { + runs: number; +} + +function createSpy(onRun?: () => void): Spy { + const spy: Spy = { + observers: new Set(), + runs: 0, + run: () => { + spy.runs++; + onRun?.(); + }, + }; + + return spy; +} + +function openBatch() { + observableBatch.current = new Set(); +} + +function flushBatch() { + const batch = observableBatch.current; + + if (!batch) { + throw new Error("flushBatch() called without an open batch"); + } + + try { + // Mirrors the flush in `StyleCollection.inject` and the `Dimensions` + // listener: effects queued *during* the flush are visited by the same loop. + for (const effect of batch) { + effect.run(); + } + } finally { + observableBatch.current = undefined; + } +} + +/** + * The ways a value can be read out of an observable. Every one of them must be + * side effect free as far as *other* subscribers are concerned. + */ +const readKinds = { + none: () => undefined, + bare: (obs: Observable) => obs.get(), + withEffect: (obs: Observable) => obs.get(createSpy()), +} as const; + +type ReadKind = keyof typeof readKinds; + +const readKindNames = Object.keys(readKinds) as ReadKind[]; + +/** + * A write, expressed as the individual steps the public API exposes. The + * interleaved read is spliced into every gap in this list, which is what makes + * the table exhaustive over "where can a read land" rather than over one + * hand-picked interleaving. + */ +function writeSteps( + source: Observable, + batched: boolean, + next: number, +): (() => void)[] { + const write = () => { + source.set(next); + }; + + return batched ? [openBatch, write, flushBatch] : [write]; +} + +/** + * How the derived observable maps its source. `collapse` is the other half of + * the class: a source change it swallows must reach no subscriber, so it pins + * the fix against over-notifying. + */ +const derivations = { + identity: (value: number) => value, + collapse: (value: number) => Math.min(value, 1), +} as const; + +type Derivation = keyof typeof derivations; + +const derivationNames = Object.keys(derivations) as Derivation[]; + +interface Scenario { + readonly batched: boolean; + readonly derivation: Derivation; + readonly readKind: ReadKind; + /** Gap in the write's step list that the interleaved read is spliced into. */ + readonly readAt: number; +} + +interface ScenarioResult { + /** How many times the subscriber was notified across the whole sequence. */ + readonly notifications: number; +} + +/** + * Includes a return to an already-seen value (3 -> 1) and a write that changes + * nothing (1 -> 1). A single write cannot distinguish "the subscriber is up to + * date" from "the guard happens to compare against the right value once". + */ +const writeSequence = [2, 3, 1, 1, 2] as const; + +function runScenario(scenario: Scenario): ScenarioResult { + const source = observable(1); + const derive = derivations[scenario.derivation]; + const derived = observable((read) => derive(read(source))); + + let notifications = 0; + // The subscriber re-reads when it runs, exactly as a re-rendering component + // does. `lastSeen` is therefore the observable's value as this subscriber + // understands it. + let lastSeen: number; + const subscriber: Effect = { + observers: new Set(), + run: () => { + notifications++; + lastSeen = derived.get(subscriber); + }, + }; + + // Subscribing *is* the first read, so this is the value the subscriber holds. + lastSeen = derived.get(subscriber); + + for (const next of writeSequence) { + const steps = writeSteps(source, scenario.batched, next); + steps.splice(scenario.readAt, 0, () => { + readKinds[scenario.readKind](derived); + }); + + for (const step of steps) { + step(); + } + + // The class: once a write has settled, no subscriber is holding a stale + // view. A read anywhere in the sequence must not change this. + expect(lastSeen).toBe(derived.get()); + } + + return { notifications }; +} + +/** How many of `writeSequence`'s writes actually move the derived value. */ +function expectedNotifications(derivation: Derivation): number { + const derive = derivations[derivation]; + let current = derive(1); + + return writeSequence.reduce((count, next) => { + const derived = derive(next); + const changed = !Object.is(derived, current); + current = derived; + + return changed ? count + 1 : count; + }, 0); +} + +function scenarios(): Scenario[] { + const table: Scenario[] = []; + + for (const batched of [false, true]) { + // A read can land before every step and after the last one. + const gaps = (batched ? 3 : 1) + 1; + + for (const derivation of derivationNames) { + for (const readKind of readKindNames) { + for (let readAt = 0; readAt < gaps; readAt++) { + table.push({ batched, derivation, readKind, readAt }); + } + } + } + } + + return table; +} + +describe("observable notifications survive an interleaved read", () => { + test.each(scenarios())( + "batched=$batched $derivation read=$readKind@$readAt", + (scenario) => { + const { notifications } = runScenario(scenario); + + // The other half of the class: a read may not manufacture a notification + // either. Only writes that move the derived value may notify. + expect(notifications).toBe(expectedNotifications(scenario.derivation)); + }, + ); + + test.each([{ coObserverFirst: true }, { coObserverFirst: false }])( + "a co-observer that reads during the fan-out (first=$coObserverFirst)", + ({ coObserverFirst }) => { + const src = observable(1); + const derived = observable((read) => read(src)); + const subscriber = createSpy(); + + // Runs during `src`'s fan-out and reads `derived` while `derived`'s own + // effect is still queued behind it. + const coObserver = createSpy(() => { + derived.get(); + }); + + if (coObserverFirst) { + src.get(coObserver); + derived.get(subscriber); + } else { + derived.get(subscriber); + src.get(coObserver); + } + + src.set(2); + + expect(coObserver.runs).toBe(1); + expect(subscriber.runs).toBe(1); + expect(derived.get()).toBe(2); + }, + ); + + test("set() still notifies after a read refreshed the cache", () => { + // `vw`/`vh`'s shape: an explicit argument wins, otherwise fall back to + // whatever the reader computes. Nothing requires that fallback to be a + // tracked observable, so a read can refresh the cache with no notification + // pending behind it to deliver the change instead. + let untracked = 1; + const derived = observable( + (_read, arg) => arg ?? untracked, + ); + const subscriber = createSpy(); + + expect(derived.get(subscriber)).toBe(1); + + untracked = 2; + // A read, not a write: it refreshes the cache but owes nobody anything. + expect(derived.get()).toBe(2); + + // The subscriber has still only ever seen 1, so this must reach it. + derived.set(2); + + expect(subscriber.runs).toBe(1); + }); +}); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..86056df8 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -32,12 +32,22 @@ export function observable( equality: (value1: Value, value2: Value) => boolean = Object.is, ) { let value: Value; + /** + * The value `observers` have been handed. `value` cannot answer "is a + * notification still owed?" because `get()` refreshes the cache on read - a + * read that lands between a write and its notification would move the cache + * onto the new value, and the guards below would then mistake the pending + * change for one that has already been delivered. Only `notify()` advances + * this, so a read can never cancel a notification. + */ + let notifiedValue: Value; let isStatic = typeof init !== "function"; let didInit: boolean | undefined; let lastArg: Arg | undefined; if (typeof init !== "function") { value = init; + notifiedValue = init; didInit = true; } @@ -47,7 +57,7 @@ export function observable( run: () => { if (!isStatic) { const nextValue = (init as Read)(getter, lastArg); - if (equality(value, nextValue)) { + if (equality(notifiedValue, nextValue)) { return; } value = nextValue; @@ -60,40 +70,53 @@ export function observable( const getter: Getter = (observable) => observable.get(effect); function get(effect?: Effect) { + // Sampled before subscribing: an observer added by this call receives the + // value this call returns, so only observers that were already registered + // can be left behind by the refresh below. + const hadObservers = observers.size > 0; + if (effect) { observers.add(effect); } if (!didInit) { value = (init as Read)(getter, undefined); + + if (!hadObservers) { + // Nobody was subscribed, so no notification can be owed for this value. + // Publishing it here keeps the first dependency change from firing a + // notification for a value the subscriber already read. + notifiedValue = value; + } } return value; } function set(arg: Arg) { + let nextValue: Value; + if (isStatic) { - if (equality(value, arg as unknown as Value)) { - return; - } - value = arg as unknown as Value; + nextValue = arg as unknown as Value; } else { - const nextValue = (init as Read)(getter, arg); + nextValue = (init as Read)(getter, arg); didInit = true; lastArg = arg; + } - if (equality(value, nextValue)) { - return; - } - value = nextValue; + if (equality(notifiedValue, nextValue)) { + return; } + value = nextValue; notify(); return obs; } function notify() { + notifiedValue = value; + Array.from(observers).forEach((observer) => { if (observableBatch.current) { observableBatch.current.add(observer); From 0f8f830559b927f2c6fd410332bef560e6e8bafa Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:56:04 +0300 Subject: [PATCH 2/5] fix(native): pin reactivity's process-global state to globalThis `package.json`'s `exports` map sends `import` to `dist/module/**` and `require` to `dist/commonjs/**`. Metro resolves per requesting module, so one app can evaluate this module twice, and every piece of state it owns was duplicated by that: two `Dimensions` listeners writing two `vw`s, two `colorScheme`s, two container families, and two `observableBatch`es. A subscriber registered through one copy never heard a write made through the other, and a batch opened by `StyleCollection.inject` on one copy did not capture writes made through the other. The state is pinned as one object rather than a global per export, matching `style-collection.ts`. The pieces are mutually coupled - `vw` and `vh` derive from `dimensions`, and the listener that writes them does so through `observableBatch` - so a per-export guard invites a later edit that shares some and not others, and a half-shared graph is harder to diagnose than an unshared one. Building it in one initializer also makes the `Dimensions` and `Appearance` registrations part of what runs once. The pure exports stay unpinned: `observable`, `family`, `weakFamily` and `cleanupEffect` close over no process state, so a second copy of them is harmless. `VAR_SYMBOL` is interned by `Symbol.for` already. The test reproduces the hazard rather than describing it: two `jest.resetModules()` + `import()` pairs evaluate the source twice against one `globalThis`, which is the same shape as the two builds. It asserts the two copies are genuinely distinct first - `observable` differs between them - so the sharing assertions cannot pass by the module simply being cached. The state census is read off the guarded object, so state added later is covered without editing the test. --- .../native/reactivity-dual-package.test.ts | 132 +++++++++++++++ src/native/reactivity.ts | 153 ++++++++++++------ 2 files changed, 232 insertions(+), 53 deletions(-) create mode 100644 src/__tests__/native/reactivity-dual-package.test.ts diff --git a/src/__tests__/native/reactivity-dual-package.test.ts b/src/__tests__/native/reactivity-dual-package.test.ts new file mode 100644 index 00000000..803364a5 --- /dev/null +++ b/src/__tests__/native/reactivity-dual-package.test.ts @@ -0,0 +1,132 @@ +import type { Effect } from "../../native/reactivity"; + +/** + * `package.json`'s `exports` map sends `import` to `dist/module/**` and + * `require` to `dist/commonjs/**`, and Metro resolves per requesting module, so + * a single app can hold two copies of this module. Two copies means two sets of + * observables and two batches, and a write through one is invisible to the + * other. + * + * `jest.resetModules()` followed by a fresh `import()` reproduces that exactly: + * the source is evaluated twice against one `globalThis`. It is also fully + * deterministic - module evaluation is synchronous and ordered, so there is no + * clock, no listener race and nothing carried between tests. + */ +type ReactivityModule = typeof import("../../native/reactivity"); + +async function loadCopy(): Promise { + jest.resetModules(); + + return import("../../native/reactivity"); +} + +function createSubscriber(): Effect & { runs: number } { + const subscriber = { + observers: new Set(), + runs: 0, + run: () => { + subscriber.runs++; + }, + }; + + return subscriber; +} + +describe("dual package hazard", () => { + test("a second evaluation really is a separate copy", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + // Vacuity guard. If the module were not re-evaluated, every sharing + // assertion below would hold for the wrong reason. `observable` is a plain + // function that closes over nothing process-global, so it is *expected* to + // differ between copies - that difference is the proof there are two. + expect(second.observable).not.toBe(first.observable); + expect(second.family).not.toBe(first.family); + }); + + test("every piece of process-global reactive state is one object", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + // The census is the guard's own state object, so a new piece of shared + // state is covered here the moment it is added rather than when someone + // remembers to extend a list. + const census = Object.keys(globalThis.__react_native_css_reactivity ?? {}); + + // An empty census would make the loop below assert nothing at all. + expect(census.length).toBeGreaterThan(0); + + for (const name of census) { + const key = name as keyof ReactivityModule; + + // Without this, a piece of state the module forgot to re-export would + // compare `undefined` against `undefined` and pass while sharing nothing. + expect(first[key]).toBeDefined(); + expect(second[key]).toBe(first[key]); + } + }); + + test("VAR_SYMBOL is shared", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + // Interned by `Symbol.for`, so this holds without a guard. Pinned because + // switching it to a bare `Symbol()` would silently split variable lookup + // across copies. + expect(second.VAR_SYMBOL).toBe(first.VAR_SYMBOL); + }); + + test("a write through one copy reaches a subscriber on the other", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + const subscriber = createSubscriber(); + const initial = second.colorScheme.get(subscriber); + const next = initial === "dark" ? "light" : "dark"; + + first.colorScheme.set(next); + + expect(subscriber.runs).toBe(1); + expect(second.colorScheme.get()).toBe(next); + }); + + test("a batch opened on one copy captures a write made through the other", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + const subscriber = createSubscriber(); + const value = second.observable(0); + value.get(subscriber); + + // `StyleCollection.inject` and the `Dimensions` listener both open a batch + // this way. If the batch is not one object, the write below runs its + // observers immediately and the batch's flush finds nothing to do. + first.observableBatch.current = new Set(); + value.set(1); + + expect(subscriber.runs).toBe(0); + expect(first.observableBatch.current.size).toBe(1); + + for (const effect of first.observableBatch.current) { + effect.run(); + } + first.observableBatch.current = undefined; + + expect(subscriber.runs).toBe(1); + }); + + test("a container observable is keyed off one family per process", async () => { + const first = await loadCopy(); + const second = await loadCopy(); + + const key = {}; + + expect(second.containerLayoutFamily(key)).toBe( + first.containerLayoutFamily(key), + ); + expect(second.hoverFamily(key)).toBe(first.hoverFamily(key)); + expect(second.activeFamily(key)).toBe(first.activeFamily(key)); + expect(second.focusFamily(key)).toBe(first.focusFamily(key)); + }); +}); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 86056df8..c1c9f936 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -23,9 +23,9 @@ export type Observable = { type Read = (get: Getter, arg?: Arg) => Value; export type Getter = (observable: Observable) => Value; -export const observableBatch: { +export type ObservableBatch = { current?: Set; -} = {}; +}; export function observable( init: Value | Read, @@ -209,63 +209,110 @@ export type VariableContextValue = Record & { [VAR_SYMBOL]: true; }; -/** Pseudo Classes ************************************************************/ - -export const hoverFamily = weakFamily(() => observable(false)); -export const activeFamily = weakFamily(() => observable(false)); -export const focusFamily = weakFamily(() => observable(false)); - -/** Dimensions ****************************************************************/ - -export const dimensions = observable(Dimensions.get("window")); -export const vw = observable( - (read, value) => value ?? read(dimensions)?.width, -); -export const vh = observable( - (read, value) => value ?? read(dimensions)?.height, -); - -Dimensions.addEventListener("change", ({ window }) => { - observableBatch.current = new Set(); - vw.set(window.width); - vh.set(window.height); +export type ContainerContextValue = Record; - for (const effect of observableBatch.current) { - effect.run(); - } +/****************************** Process globals *******************************/ + +/** + * Everything below this point is process-global reactive state, and every piece + * of it is duplicated under the dual package hazard: `package.json`'s `exports` + * map sends `import` to `dist/module/**` and `require` to `dist/commonjs/**`, + * and Metro resolves per requesting module, so one app can evaluate this module + * twice. Two copies means two `Dimensions` listeners writing two `vw`s, and a + * subscriber registered through one copy never hears a write made through the + * other. + * + * It is pinned as ONE object rather than a global per export because the state + * is mutually coupled - `vw`/`vh` derive from `dimensions`, and the listener + * that writes them does so through `observableBatch`. A per-export guard lets a + * later edit share some and not others, which yields a half-shared graph that + * is harder to diagnose than no guard at all. Building it in one initializer + * also makes the listener registrations part of what runs exactly once. + * + * The pure exports above (`observable`, `family`, `cleanupEffect`, ...) are + * deliberately NOT pinned: they close over no process state, so a second copy + * of them is harmless. `VAR_SYMBOL` is interned by `Symbol.for` and is already + * shared by construction. + */ +function createReactivityState() { + const dimensions = observable(Dimensions.get("window")); + const vw = observable( + (read, value) => value ?? read(dimensions)?.width, + ); + const vh = observable( + (read, value) => value ?? read(dimensions)?.height, + ); - observableBatch.current = undefined; -}); + Dimensions.addEventListener("change", ({ window }) => { + observableBatch.current = new Set(); + vw.set(window.width); + vh.set(window.height); -/** Color Scheme **************************************************************/ + for (const effect of observableBatch.current) { + effect.run(); + } -export const colorScheme = observable( - Appearance.getColorScheme(), -); -Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); + observableBatch.current = undefined; + }); -/** Containers ****************************************************************/ + const colorScheme = observable(Appearance.getColorScheme()); + Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); -export type ContainerContextValue = Record; -export const ContainerContext = createContext({}); - -export const containerLayoutFamily = weakFamily(() => { - return observable({ - x: 0, - y: 0, - width: 0, - height: 0, + const containerLayoutFamily = weakFamily(() => { + return observable({ + x: 0, + y: 0, + width: 0, + height: 0, + }); }); -}); -export const containerWidthFamily = weakFamily((key) => { - return observable((read) => { - return read(containerLayoutFamily(key))?.width || 0; - }); -}); + return { + observableBatch: {} as ObservableBatch, + + hoverFamily: weakFamily(() => observable(false)), + activeFamily: weakFamily(() => observable(false)), + focusFamily: weakFamily(() => observable(false)), + + dimensions, + vw, + vh, + colorScheme, + + ContainerContext: createContext({}), + containerLayoutFamily, + containerWidthFamily: weakFamily((key: WeakKey) => { + return observable((read) => { + return read(containerLayoutFamily(key))?.width || 0; + }); + }), + containerHeightFamily: weakFamily((key: WeakKey) => { + return observable((read) => { + return read(containerLayoutFamily(key))?.width || 0; + }); + }), + }; +} -export const containerHeightFamily = weakFamily((key) => { - return observable((read) => { - return read(containerLayoutFamily(key))?.width || 0; - }); -}); +declare global { + var __react_native_css_reactivity: + | ReturnType + | undefined; +} + +globalThis.__react_native_css_reactivity ??= createReactivityState(); + +export const { + observableBatch, + hoverFamily, + activeFamily, + focusFamily, + dimensions, + vw, + vh, + colorScheme, + ContainerContext, + containerLayoutFamily, + containerWidthFamily, + containerHeightFamily, +} = globalThis.__react_native_css_reactivity; From b4a532f2a1e2579d1f1cefcf91f14aa305228ea4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:01:06 +0300 Subject: [PATCH 3/5] fix(native): inject universal variables into their own family `* { --x: ... }` compiles to `vu` and `:root { --x: ... }` compiles to `vr`, and the resolver consults `universalVariables` before `rootVariables`. The injector wrote both into `rootVariables`, so `universalVariables` was never populated and the resolver's universal branch was dead. That is not only dead code. `set` replaces a variable's whole value list, and the `vu` loop runs after the `vr` loop, so a universal declaration overwrote the root declaration of the same name. When the universal declaration sits behind a media query that does not match, it resolves to nothing and the root value it replaced is gone: :root { --my-var: #123456; } @media (min-width: 99999px) { * { --my-var: #abcdef; } } resolved to no colour at all instead of falling back to `#123456`. Injecting `vu` into `universalVariables` keeps the two censuses apart, so each resolves independently and the resolver's existing order decides between them. That order is deliberate and now asserted: `*` outranks `:root` for any non-root element, because a `*` declaration applies to the element directly while a `:root` declaration only reaches it by inheritance. It held before only because one census was always empty, so swapping the two reads broke nothing. The tests cover both directions of the fallback and both rankings, and a swap of the resolver's two reads now fails. --- .../native/universal-variables.test.tsx | 85 +++++++++++++++++++ src/native-internal/style-collection.ts | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/native/universal-variables.test.tsx diff --git a/src/__tests__/native/universal-variables.test.tsx b/src/__tests__/native/universal-variables.test.tsx new file mode 100644 index 00000000..e618b347 --- /dev/null +++ b/src/__tests__/native/universal-variables.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { dimensions } from "../../native/reactivity"; + +/** + * `:root` and `*` are two different variable censuses. The compiler already + * keeps them apart - `:root` compiles to `vr`, `*` compiles to `vu` - and the + * resolver already consults universal before root. + * + * `*` outranking `:root` is correct CSS for any non-root element: a `*` + * declaration applies to the element directly, while a `:root` declaration + * only reaches it by inheritance, and a direct declaration wins. These tests + * make that ranking an asserted contract rather than a side effect of one + * census being empty. + */ + +// Every viewport in these tests is far below this, so the query never matches. +const neverMatches = "(min-width: 99999px)"; + +beforeEach(() => { + // The media query verdicts below are read off `vw`, so the viewport is stated + // rather than inherited from whatever the previous test left behind. + dimensions.set({ width: 750, height: 1334, scale: 2, fontScale: 2 }); +}); + +test("a universal variable outranks a root variable of the same name", () => { + registerCSS(` + :root { --my-var: #123456; } + * { --my-var: #abcdef; } + .my-class { color: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#abcdef", + }); +}); + +test("a root variable resolves when no universal variable is declared", () => { + registerCSS(` + :root { --my-var: #123456; } + .my-class { color: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); +}); + +test("a universal variable behind an unmatched media query falls back to root", () => { + registerCSS(` + :root { --my-var: #123456; } + @media ${neverMatches} { * { --my-var: #abcdef; } } + .my-class { color: var(--my-var); } + `); + + render(); + + // The universal declaration does not apply at this viewport, so the root + // declaration is what is left. Collapsing both censuses into one family + // loses this, because the universal entry overwrites the root entry and then + // resolves to nothing. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); +}); + +test("a root variable behind an unmatched media query falls back to universal", () => { + registerCSS(` + @media ${neverMatches} { :root { --my-var: #123456; } } + * { --my-var: #abcdef; } + .my-class { color: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#abcdef", + }); +}); diff --git a/src/native-internal/style-collection.ts b/src/native-internal/style-collection.ts index eff34009..21a296a6 100644 --- a/src/native-internal/style-collection.ts +++ b/src/native-internal/style-collection.ts @@ -91,7 +91,7 @@ globalThis.__react_native_css_style_collection ??= { if (options.vu) { for (const entry of options.vu) { - rootVariables(entry[0]).set(entry[1]); + universalVariables(entry[0]).set(entry[1]); } } From c40371f1bc9e3b8f5cd1c41da14febb2ef94cc3b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:11:01 +0300 Subject: [PATCH 4/5] fix(jest): clear root and universal variables between tests The harness cleared `StyleCollection.styles` between tests but left the `:root` and `*` variable families alone. Both are process-global, so a `vr` or `vu` entry injected by one test resolved in every test after it. Injecting universal variables into their own family makes this worse, since there are now two registries carrying state nobody clears. `resetGlobalVariables` clears both and re-applies the variables the runtime declares for itself. Those cannot simply survive the clear - `__rn-css-rem` backs every relative length, so dropping it would resolve every `em` and `rem` to nothing - so seeding moves into a function that both module init and the reset call. No existing test changes behaviour: 1097 passing before, 1097 after, with the same three pre-existing babel failures. The tests drive the reset directly rather than relying on a previous test having dirtied the registries, so each passes alone and in any order. The end-to-end case declares its variable twice, once behind a media query that cannot match. A `:root` variable with a single declaration is folded into the rule by the compiler, so a test written the obvious way never reaches the registry at all and holds whatever the reset does. --- .../native/root-variable-reset.test.tsx | 66 +++++++++++++++++++ src/jest/index.ts | 4 ++ src/native-internal/root.ts | 41 +++++++++--- 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/native/root-variable-reset.test.tsx diff --git a/src/__tests__/native/root-variable-reset.test.tsx b/src/__tests__/native/root-variable-reset.test.tsx new file mode 100644 index 00000000..8f4fe612 --- /dev/null +++ b/src/__tests__/native/root-variable-reset.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +import { + resetGlobalVariables, + rootVariables, + universalVariables, +} from "../../native-internal/root"; + +/** + * `:root` and `*` variables live in two process-global families that no test + * owns. Clearing `StyleCollection.styles` between tests does not touch them, so + * a `vr` or `vu` entry injected by one test resolves in the next one. + * + * Every test here drives the reset itself rather than relying on a previous + * test having dirtied the registries, so each passes alone and in any order. + */ + +test("resetGlobalVariables drops injected root and universal variables", () => { + rootVariables("injected-root").set([["#123456"]]); + universalVariables("injected-universal").set([["#abcdef"]]); + + expect(rootVariables("injected-root").get()).toBe("#123456"); + expect(universalVariables("injected-universal").get()).toBe("#abcdef"); + + resetGlobalVariables(); + + expect(rootVariables("injected-root").get()).toBeUndefined(); + expect(universalVariables("injected-universal").get()).toBeUndefined(); +}); + +test("resetGlobalVariables keeps the variables the runtime declares itself", () => { + resetGlobalVariables(); + + // `rem` backs every `em`/`rem` unit, so dropping it would silently resolve + // every relative length to nothing. + expect(rootVariables("__rn-css-rem").get()).toBe(14); + expect(rootVariables("__rn-css-color").get()).toBeDefined(); +}); + +test("a variable injected by one stylesheet does not survive a reset", () => { + // The unmatched second declaration keeps `--leaky` out of the compiler's + // static fold, so it really is injected into `rootVariables` and the reset + // below is what has to remove it. + registerCSS(` + :root { --leaky: #123456; } + @media (min-width: 99999px) { :root { --leaky: #abcdef; } } + .my-class { color: var(--leaky); } + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); + + // Exactly what the jest harness does between tests. + StyleCollection.styles.clear(); + resetGlobalVariables(); + + registerCSS(`.my-class { color: var(--leaky); }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index cc125390..df467d05 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 { resetGlobalVariables } 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(); + // `:root` and `*` variables are process-global too, so a `vr`/`vu` entry + // injected by one test resolves in the next one unless it is dropped here. + resetGlobalVariables(); 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..8fc0fd36 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,13 +32,34 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); -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); +/** + * The variables the runtime declares for itself rather than reading out of a + * stylesheet. Applied again after every reset, because `rem` backs every + * relative length and clearing it would resolve them all to nothing. + */ +function applyBuiltInVariables() { + 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); +} + +/** + * Drops every stylesheet-declared variable, from both the `:root` family and + * the `*` family. Both are process-global and nothing else clears them, so a + * harness that resets between cases needs this alongside + * `StyleCollection.styles.clear()`. + */ +export function resetGlobalVariables() { + rootVariables.clear(); + universalVariables.clear(); + applyBuiltInVariables(); +} + +applyBuiltInVariables(); From b790c895d7b11339b63bf28307d27c13ae2e7d50 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:11:24 +0300 Subject: [PATCH 5/5] test(native): make the root variable case reach the runtime registry A `:root` custom property with exactly one declaration is folded straight into the consuming rule by the compiler - the stylesheet carries no `vr` entry at all, and `color: var(--my-var)` compiles to `color: #123456`. A test written that way asserts the compiler's constant folding and never reaches `rootVariables`, so it holds whatever the registry does. Declaring the variable a second time behind a media query that cannot match keeps it dynamic. Removing the resolver's `rootVariables` branch now fails this test; before, it passed. --- src/__tests__/native/universal-variables.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/native/universal-variables.test.tsx b/src/__tests__/native/universal-variables.test.tsx index e618b347..c9eaed53 100644 --- a/src/__tests__/native/universal-variables.test.tsx +++ b/src/__tests__/native/universal-variables.test.tsx @@ -40,8 +40,13 @@ test("a universal variable outranks a root variable of the same name", () => { }); test("a root variable resolves when no universal variable is declared", () => { + // The second `:root` declaration is what keeps this dynamic. A `:root` + // variable with exactly one declaration is folded into the rule by the + // compiler, so the runtime registry is never consulted and the test would + // pass no matter what the registry held. registerCSS(` :root { --my-var: #123456; } + @media ${neverMatches} { :root { --my-var: #abcdef; } } .my-class { color: var(--my-var); } `);