From 7cb4332dad54ea6543e240b1a2040a0add2c63d2 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 13 Aug 2026 00:06:17 +0300 Subject: [PATCH 1/2] fix(native): share the :root variable registries across module copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `native-internal/root` created its two registries at module scope, so two copies of the module meant two independent stores. The package's `exports` map splits `import` and `require` onto different builds and Metro resolves that condition per REQUESTING module, so a compiled-CommonJS dependency and first-party source bind different copies. A `:root` variable injected into one was invisible to the other, and the value silently fell back to its seed — a themed class rendered React Native's default rather than the theme's colour, with no error. Every other stateful module here already guards against this (`style-collection.ts`, `variables.tsx`); these two did not. Creation and seeding are ONE step, behind one global. A `??=` on the registries alone would leave the seeds running unconditionally, so a copy initialising AFTER the stylesheet inject would re-run `set([[14]])` and clobber a project's own `:root { font-size: 16px }` back to 14 — silently rescaling every rem-derived value to 87.5%. Both registries share one global for the same reason: two globals could be half-initialised. The guard is a named `resolveRootVariableRegistries()` so a second copy's behaviour is reachable from a test without re-evaluating the module — `await import()` and `require()` are both unavailable here (no `--experimental-vm-modules`, and the lint config forbids the latter). 3 tests, each mutation-proven to go red when the guard is removed. --- .../native/root-variable-registry.test.ts | 47 ++++++++++++ src/native-internal/root.ts | 74 +++++++++++++++---- 2 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/native/root-variable-registry.test.ts diff --git a/src/__tests__/native/root-variable-registry.test.ts b/src/__tests__/native/root-variable-registry.test.ts new file mode 100644 index 00000000..87ccde07 --- /dev/null +++ b/src/__tests__/native/root-variable-registry.test.ts @@ -0,0 +1,47 @@ +import { + resolveRootVariableRegistries, + rootVariables, + universalVariables, +} from "../../native-internal/root"; + +/** + * The `:root` registries are global, and created + seeded exactly once. + * + * The package's `exports` map splits `import` and `require` onto different + * builds, and Metro resolves that condition per requesting module — so a + * compiled-CommonJS dependency and first-party source bind different copies of + * `native-internal/root`. Module-scope registries made that two independent + * stores: a `:root` variable injected into one copy was invisible to the + * other, and the value silently fell back to its seed. + * + * `resolveRootVariableRegistries` is what a second copy of the module runs, so + * calling it directly reproduces the second copy without needing the module + * itself to be re-evaluated. + */ +test("the module's exports are the registries published on globalThis", () => { + const registries = globalThis.__react_native_css_root_variable_registries; + + expect(registries).toBeDefined(); + expect(rootVariables).toBe(registries?.root); + expect(universalVariables).toBe(registries?.universal); +}); + +test("a second copy resolves the same registries", () => { + // Captured BEFORE the call: comparing against the global afterwards passes + // even when the resolver replaces it, which is no assertion at all. + const before = globalThis.__react_native_css_root_variable_registries; + + expect(resolveRootVariableRegistries()).toBe(before); +}); + +test("a second copy does not re-seed over an injected value", () => { + // The regression a `??=` on the registries ALONE would ship: the seeds run + // unconditionally, so a copy initialising AFTER the stylesheet inject + // clobbers a project's `:root { font-size: 16px }` back to 14 and rescales + // every rem-derived value to 87.5%. + expect(rootVariables("__rn-css-rem").get()).toBe(14); + + rootVariables("__rn-css-rem").set([[16]]); + + expect(resolveRootVariableRegistries().root("__rn-css-rem").get()).toBe(16); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..1002def6 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -29,16 +29,64 @@ 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); +interface RootVariableRegistries { + root: ReturnType; + universal: ReturnType; +} + +declare global { + var __react_native_css_root_variable_registries: + | RootVariableRegistries + | undefined; +} + +/** + * Create BOTH registries and seed them, as one step. + * + * Creating and seeding cannot be split. A bare `??=` on the registries alone + * would leave the seeds running unconditionally, so a second copy initialising + * AFTER the stylesheet inject re-runs `set([[14]])` and clobbers a project's + * own `:root { font-size: 16px }` back to 14 — silently rescaling every + * rem-derived value to 87.5%. Both registries live behind ONE global for the + * same reason: two globals could be half-initialised. + */ +function createRootVariableRegistries(): RootVariableRegistries { + const registries: RootVariableRegistries = { + root: rootVariableFamily(), + universal: rootVariableFamily(), + }; + + registries.root("__rn-css-rem").set([[14]]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + registries.root("__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); + + return registries; +} + +/** + * The `:root` registries are GLOBAL. + * + * The package's `exports` map splits `import` and `require` onto different + * builds and Metro resolves that condition per REQUESTING module, so a + * compiled-CommonJS dependency and first-party source bind different copies of + * this file. Every other stateful module here already guards against that + * (`style-collection.ts`, `variables.tsx`); these two held runtime state and + * did not, so a `:root` variable injected into one copy was invisible to the + * other and the value silently fell back to its seed. + */ +export function resolveRootVariableRegistries(): RootVariableRegistries { + return (globalThis.__react_native_css_root_variable_registries ??= + createRootVariableRegistries()); +} + +const registries = resolveRootVariableRegistries(); + +export const rootVariables = registries.root; +export const universalVariables = registries.universal; From 0a4018c87ec7a531e608b5addcbde70b6c88aa5d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 20:31:54 +0300 Subject: [PATCH 2/2] refactor(native): inline the registry guard and test a real second copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard now sits at module scope in the shape style-collection.ts and variables.tsx already use, so `resolveRootVariableRegistries` is gone from `react-native-css/native-internal`'s public surface. It was exported so a test could reach a second copy's behaviour, and that is not needed: `jest.resetModules()` plus a re-import evaluates the module again against the same globalThis, which is the dual-package case exactly. The test that replaces it holds an injected rem of 16 across that second evaluation, and goes red when the seeds are moved outside the guard — the regression the test it replaces named but did not catch. --- .../native/root-variable-registry.test.ts | 47 ++++++------------- src/native-internal/root.ts | 41 +++++----------- 2 files changed, 26 insertions(+), 62 deletions(-) diff --git a/src/__tests__/native/root-variable-registry.test.ts b/src/__tests__/native/root-variable-registry.test.ts index 87ccde07..48ea3cf5 100644 --- a/src/__tests__/native/root-variable-registry.test.ts +++ b/src/__tests__/native/root-variable-registry.test.ts @@ -1,23 +1,5 @@ -import { - resolveRootVariableRegistries, - rootVariables, - universalVariables, -} from "../../native-internal/root"; +import { rootVariables, universalVariables } from "../../native-internal/root"; -/** - * The `:root` registries are global, and created + seeded exactly once. - * - * The package's `exports` map splits `import` and `require` onto different - * builds, and Metro resolves that condition per requesting module — so a - * compiled-CommonJS dependency and first-party source bind different copies of - * `native-internal/root`. Module-scope registries made that two independent - * stores: a `:root` variable injected into one copy was invisible to the - * other, and the value silently fell back to its seed. - * - * `resolveRootVariableRegistries` is what a second copy of the module runs, so - * calling it directly reproduces the second copy without needing the module - * itself to be re-evaluated. - */ test("the module's exports are the registries published on globalThis", () => { const registries = globalThis.__react_native_css_root_variable_registries; @@ -26,22 +8,21 @@ test("the module's exports are the registries published on globalThis", () => { expect(universalVariables).toBe(registries?.universal); }); -test("a second copy resolves the same registries", () => { - // Captured BEFORE the call: comparing against the global afterwards passes - // even when the resolver replaces it, which is no assertion at all. - const before = globalThis.__react_native_css_root_variable_registries; +test("a second copy of the module shares the registries and does not re-seed", async () => { + // 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, so two copies of this file evaluate in one bundle + const firstCopy = await import("../../native-internal/root"); - expect(resolveRootVariableRegistries()).toBe(before); -}); + expect(firstCopy.rootVariables("__rn-css-rem").get()).toBe(14); + firstCopy.rootVariables("__rn-css-rem").set([[16]]); -test("a second copy does not re-seed over an injected value", () => { - // The regression a `??=` on the registries ALONE would ship: the seeds run - // unconditionally, so a copy initialising AFTER the stylesheet inject - // clobbers a project's `:root { font-size: 16px }` back to 14 and rescales - // every rem-derived value to 87.5%. - expect(rootVariables("__rn-css-rem").get()).toBe(14); + jest.resetModules(); + const secondCopy = await import("../../native-internal/root"); - rootVariables("__rn-css-rem").set([[16]]); + // The module body really re-ran, so the assertions below are about two copies + expect(secondCopy).not.toBe(firstCopy); - expect(resolveRootVariableRegistries().root("__rn-css-rem").get()).toBe(16); + expect(secondCopy.rootVariables).toBe(firstCopy.rootVariables); + expect(secondCopy.rootVariables("__rn-css-rem").get()).toBe(16); }); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 1002def6..0e86502b 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -40,16 +40,9 @@ declare global { | undefined; } -/** - * Create BOTH registries and seed them, as one step. - * - * Creating and seeding cannot be split. A bare `??=` on the registries alone - * would leave the seeds running unconditionally, so a second copy initialising - * AFTER the stylesheet inject re-runs `set([[14]])` and clobbers a project's - * own `:root { font-size: 16px }` back to 14 — silently rescaling every - * rem-derived value to 87.5%. Both registries live behind ONE global for the - * same reason: two globals could be half-initialised. - */ +// Creating and seeding are one step. Guarding only the creation leaves the seeds running +// unconditionally, so a copy initialising after the stylesheet inject clobbers a project's +// `:root { font-size: 16px }` back to 14 function createRootVariableRegistries(): RootVariableRegistries { const registries: RootVariableRegistries = { root: rootVariableFamily(), @@ -70,23 +63,13 @@ function createRootVariableRegistries(): RootVariableRegistries { return registries; } -/** - * The `:root` registries are GLOBAL. - * - * The package's `exports` map splits `import` and `require` onto different - * builds and Metro resolves that condition per REQUESTING module, so a - * compiled-CommonJS dependency and first-party source bind different copies of - * this file. Every other stateful module here already guards against that - * (`style-collection.ts`, `variables.tsx`); these two held runtime state and - * did not, so a `:root` variable injected into one copy was invisible to the - * other and the value silently fell back to its seed. - */ -export function resolveRootVariableRegistries(): RootVariableRegistries { - return (globalThis.__react_native_css_root_variable_registries ??= - createRootVariableRegistries()); -} - -const registries = resolveRootVariableRegistries(); +// Global, 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 a +// compiled-CommonJS dependency and first-party source bind different copies of this file +globalThis.__react_native_css_root_variable_registries ??= + createRootVariableRegistries(); -export const rootVariables = registries.root; -export const universalVariables = registries.universal; +export const rootVariables = + globalThis.__react_native_css_root_variable_registries.root; +export const universalVariables = + globalThis.__react_native_css_root_variable_registries.universal;