From f92e7af5e1d51a384aa3b5b791d20c999311bf91 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:22:10 +0300 Subject: [PATCH] fix(native): share reactivity state across a dual-package split package.json's exports map sends import and require to two builds, so one app can evaluate the reactivity module twice. Each copy built its own dimensions, colour scheme, batch, interaction and container families and its own ContainerContext, and registered its own Dimensions and Appearance listeners: a write through one copy never reached a subscriber on the other, a batch opened by one let the other's writes run at once, and a container provided through one context was invisible to a consumer reading the other. The process-global state is created once and pinned on globalThis, the way the style collection and the variable context already are, and every copy exports the pinned objects. --- .../native/reactivity-dual-package.test.ts | 92 +++++++++++ src/native/reactivity.ts | 143 +++++++++++------- 2 files changed, 179 insertions(+), 56 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..3f759fec --- /dev/null +++ b/src/__tests__/native/reactivity-dual-package.test.ts @@ -0,0 +1,92 @@ +import type { Effect } from "../../native/reactivity"; + +type ReactivityModule = typeof import("../../native/reactivity"); + +// A fresh module registry evaluates the module again against the same globalThis, as a second build in one app does +function loadCopy(): ReactivityModule { + jest.resetModules(); + return jest.requireActual("../../native/reactivity"); +} + +function createSubscriber(): Effect & { runs: number } { + const subscriber: Effect & { runs: number } = { + observers: new Set(), + runs: 0, + run: () => { + subscriber.runs++; + }, + }; + + return subscriber; +} + +describe("two evaluations of the reactivity module", () => { + test("are two copies", () => { + const first = loadCopy(); + const second = loadCopy(); + + expect(second.observable).not.toBe(first.observable); + }); + + test("share every piece of process-global state", () => { + const first = loadCopy(); + const second = loadCopy(); + const census = Object.keys(globalThis.__react_native_css_reactivity ?? {}); + + expect(census.length).toBeGreaterThan(0); + + for (const name of census) { + const key = name as keyof ReactivityModule; + + expect(first[key]).toBeDefined(); + expect(second[key]).toBe(first[key]); + } + }); + + test("deliver a write through one to a subscriber on the other", () => { + const first = loadCopy(); + const second = loadCopy(); + const subscriber = createSubscriber(); + const next = + second.colorScheme.get(subscriber) === "dark" ? "light" : "dark"; + + first.colorScheme.set(next); + + expect(subscriber.runs).toBe(1); + expect(second.colorScheme.get()).toBe(next); + }); + + test("batch a write through one inside a batch the other opened", () => { + const first = loadCopy(); + const second = loadCopy(); + const subscriber = createSubscriber(); + const value = second.observable(0); + value.get(subscriber); + + 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("key a component's interaction and layout observables off one family", () => { + const first = loadCopy(); + const second = 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 296988a6..e2a5ed28 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -24,9 +24,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, @@ -198,42 +198,7 @@ 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); - - for (const effect of observableBatch.current) { - effect.run(); - } - - observableBatch.current = undefined; -}); - -/** Color Scheme **************************************************************/ - -export const colorScheme = observable( - Appearance.getColorScheme(), -); -Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); - -/** Containers ****************************************************************/ +/******************************* Process globals ******************************/ export type ContainerContextValue = Record< string, @@ -242,25 +207,91 @@ export type ContainerContextValue = Record< props: Record | null | undefined; } >; -export const ContainerContext = createContext({}); - -export 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; +// The exports map sends import and require to two builds, so one app can evaluate this module twice; one pinned object keeps a single copy of every observable, context and listener +function createReactivityState() { + const observableBatch: ObservableBatch = {}; + + const dimensions = observable(Dimensions.get("window")); + const vw = observable( + (read, value) => value ?? read(dimensions)?.width, + ); + 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); + + for (const effect of observableBatch.current) { + effect.run(); + } + + observableBatch.current = undefined; }); -}); -export const containerHeightFamily = weakFamily((key) => { - return observable((read) => { - return read(containerLayoutFamily(key))?.height || 0; + const colorScheme = observable( + Appearance.getColorScheme(), + ); + Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); + + const containerLayoutFamily = weakFamily(() => { + return observable({ + x: 0, + y: 0, + width: 0, + height: 0, + }); }); -}); + + return { + 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))?.height || 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;