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/__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/__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/__tests__/native/universal-variables.test.tsx b/src/__tests__/native/universal-variables.test.tsx new file mode 100644 index 00000000..c9eaed53 --- /dev/null +++ b/src/__tests__/native/universal-variables.test.tsx @@ -0,0 +1,90 @@ +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", () => { + // 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); } + `); + + 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/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(); 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]); } } diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..c1c9f936 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -23,21 +23,31 @@ 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, 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); @@ -186,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;