From 857b07ebb751b061b39c45a5ceba013f035f6153 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 12:53:47 +0300 Subject: [PATCH 1/4] fix(native): subscribe the style effect to what a committed render read A render subscribed the component's style effect to every observable it read, and updateRules unsubscribed it from the previous styles, so a render React discards (a suspended transition, a Suspense fallback) left the effect on the observables of a component that never mounted. Every later change to those styles ran that component's effect ("Can't perform a React state update on a component that hasn't mounted yet"), and the discarded render could detach a mounted component from the styles it still shows. A render now reads through a getter that subscribes nothing and records each read beside the observable's version, and getStyledProps takes that getter, so every read obeys the same gate. After every commit the style effect observes exactly what that render read, releases what it no longer reads, and re-renders when a read moved since the render. Observables carry a version, and a computed value keeps the sources it was computed from with their versions, so a read without a subscriber is served from cache until a source changes, and a first subscriber keeps the value instead of computing it again. --- .../native/reactivity-dependencies.test.tsx | 121 ++++++++++++++- .../native/reactivity-lifecycle.test.tsx | 136 ++++++++++++++++- src/native/react/rules.ts | 6 +- src/native/react/useNativeCss.ts | 25 ++- src/native/reactivity.ts | 144 +++++++++++++----- src/native/styles/index.ts | 4 +- 6 files changed, 388 insertions(+), 48 deletions(-) diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx index 52d4cd3b..da593772 100644 --- a/src/__tests__/native/reactivity-dependencies.test.tsx +++ b/src/__tests__/native/reactivity-dependencies.test.tsx @@ -2,8 +2,11 @@ import { cleanupEffect, family, observable, + observeReads, + recordReads, weakFamily, type Effect, + type Getter, } from "../../native/reactivity"; test.each([0, false, "", null, undefined, NaN])( @@ -58,7 +61,7 @@ test("computed dependencies follow a conditional branch even when its value is u const first = observable(10); const second = observable(10); const selected = observable(true); - const compute = jest.fn((get: import("../../native/reactivity").Getter) => + const compute = jest.fn((get: Getter) => get(selected) ? get(first) : get(second), ); const value = observable(compute); @@ -104,3 +107,119 @@ test("an explicit computed argument replaces its previous dependency", () => { cleanupEffect(subscriber); expect(second.observers.size).toBe(0); }); + +test("a computed value read without a subscriber is computed once until a source changes", () => { + const source = observable(10); + const compute = jest.fn((get: Getter) => ({ doubled: get(source) * 2 })); + const value = observable(compute); + const first = value.get(); + expect(value.get()).toBe(first); + expect(compute).toHaveBeenCalledTimes(1); + source.set(20); + expect(value.get()).toEqual({ doubled: 40 }); + expect(compute).toHaveBeenCalledTimes(2); +}); + +test("a read without a subscriber subscribes nothing", () => { + const source = observable(10); + const value = observable((get) => get(source) * 2); + value.get(); + expect(source.observers.size).toBe(0); + expect(value.observers.size).toBe(0); +}); + +test("a first subscriber keeps the value an unsubscribed read computed", () => { + const source = observable(10); + const compute = jest.fn((get: Getter) => ({ doubled: get(source) * 2 })); + const value = observable(compute); + const read = value.get(); + const run = jest.fn(); + const subscriber: Effect = { observers: new Set(), run }; + expect(value.get(subscriber)).toBe(read); + expect(compute).toHaveBeenCalledTimes(1); + source.set(20); + expect(run).toHaveBeenCalledTimes(1); + expect(value.get(subscriber)).toEqual({ doubled: 40 }); + cleanupEffect(subscriber); +}); + +test("a chain of computed values read without subscribers follows a change at its root", () => { + const root = observable(1); + const middle = observable((get) => get(root) + 1); + const leaf = observable((get) => get(middle) * 10); + expect(leaf.get()).toBe(20); + root.set(4); + expect(leaf.get()).toBe(50); + expect(root.observers.size).toBe(0); +}); + +test("a value released by its last subscriber is not computed again until a source changes", () => { + const source = observable(10); + const compute = jest.fn((get: Getter) => get(source) * 2); + const value = observable(compute); + const subscriber: Effect = { observers: new Set(), run: jest.fn() }; + value.get(subscriber); + cleanupEffect(subscriber); + expect(value.get()).toBe(20); + expect(compute).toHaveBeenCalledTimes(1); + source.set(30); + expect(value.get()).toBe(60); + expect(compute).toHaveBeenCalledTimes(2); +}); + +test("an observable's version moves only when its value changes", () => { + const source = observable(1); + const parity = observable((get) => get(source) % 2); + const sourceVersion = source.version; + parity.get(); + const parityVersion = parity.version; + source.set(1); + expect(source.version).toBe(sourceVersion); + source.set(3); + expect(source.version).not.toBe(sourceVersion); + parity.get(); + expect(parity.version).toBe(parityVersion); + source.set(4); + parity.get(); + expect(parity.version).not.toBe(parityVersion); +}); + +test("recorded reads subscribe nothing until a commit observes them", () => { + const source = observable(10); + const value = observable((get) => get(source) * 2); + const recorded = recordReads(); + expect(recorded.read(value)).toBe(20); + expect(value.observers.size).toBe(0); + const subscriber: Effect = { observers: new Set(), run: jest.fn() }; + expect(observeReads(subscriber, recorded.reads)).toBe("unmoved"); + expect(value.observers.has(subscriber)).toBe(true); + cleanupEffect(subscriber); +}); + +test("observing recorded reads answers moved for a change since the read", () => { + const source = observable(10); + const value = observable((get) => get(source) * 2); + const recorded = recordReads(); + recorded.read(value); + source.set(30); + const run = jest.fn(); + const subscriber: Effect = { observers: new Set(), run }; + expect(observeReads(subscriber, recorded.reads)).toBe("moved"); + expect(run).not.toHaveBeenCalled(); + cleanupEffect(subscriber); +}); + +test("observing recorded reads releases what the effect observed and the commit no longer read", () => { + const first = observable(1); + const second = observable(2); + const subscriber: Effect = { observers: new Set(), run: jest.fn() }; + const before = recordReads(); + before.read(first); + observeReads(subscriber, before.reads); + const after = recordReads(); + after.read(second); + observeReads(subscriber, after.reads); + expect(first.observers.size).toBe(0); + expect(second.observers.has(subscriber)).toBe(true); + cleanupEffect(subscriber); +}); diff --git a/src/__tests__/native/reactivity-lifecycle.test.tsx b/src/__tests__/native/reactivity-lifecycle.test.tsx index 4e3047f7..45ef6db5 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from "react"; +import { startTransition, StrictMode, Suspense, use } from "react"; import { Text } from "react-native"; -import { act, render } from "@testing-library/react-native"; +import { act, render, renderAsync } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; import { useNativeVariable } from "react-native-css/native"; @@ -11,6 +11,7 @@ import { } from "react-native-css/native-internal"; import { cleanupEffect, observable, vw } from "../../native/reactivity"; +import * as calculatePropsModule from "../../native/styles/calculate-props"; test("cleaned observers receive no later notifications", () => { const value = observable(0); @@ -156,6 +157,137 @@ test("StrictMode viewport consumers remain reactive and release subscriptions", }); }); +function captureConsoleErrors() { + const error = jest.spyOn(console, "error").mockImplementation(jest.fn()); + return { + messages: () => error.mock.calls.map(([message]) => String(message)), + restore: () => { + error.mockRestore(); + }, + }; +} + +const neverResolves = new Promise(jest.fn()); + +test("a viewport change between render and commit reaches the element once it commits", () => { + registerCSS(".audit-render-window { width: 10vw; }"); + const original = vw.get(); + const errors = captureConsoleErrors(); + function ResizeDuringRender() { + vw.set(800); + return null; + } + try { + const screen = render( + <> + + + , + ); + expect(errors.messages()).toEqual([]); + expect(screen.getByTestId("sample").props.style.width).toBe(80); + screen.unmount(); + } finally { + errors.restore(); + act(() => { + vw.set(original); + }); + } +}); + +function NeverResolves() { + return use(neverResolves); +} + +test("a render React discards leaves no subscription and takes no later update", async () => { + registerCSS(".audit-discarded-render { width: 10vw; }"); + const original = vw.get(); + const before = vw.observers.size; + const errors = captureConsoleErrors(); + try { + const screen = await renderAsync( + loading}> + + + , + ); + expect(screen.getByTestId("fallback")).toBeTruthy(); + expect(screen.queryByTestId("sample")).toBeNull(); + expect(vw.observers.size).toBe(before); + await act(() => { + vw.set(900); + return Promise.resolve(); + }); + expect(errors.messages()).toEqual([]); + await screen.unmountAsync(); + } finally { + errors.restore(); + act(() => { + vw.set(original); + }); + } +}); + +test("mounting an element computes its styles once", () => { + registerCSS(".audit-single-computation { width: 10vw; }"); + const computations = jest.spyOn(calculatePropsModule, "calculateProps"); + try { + const screen = render( + , + ); + expect(screen.getByTestId("sample").props.style.width).toBeGreaterThan(0); + expect(computations).toHaveBeenCalledTimes(1); + screen.unmount(); + } finally { + computations.mockRestore(); + } +}); + +test("a render React discards leaves the committed element subscribed", async () => { + registerCSS( + ".audit-kept-first { width: 10vw; } .audit-kept-second { width: 20px; }", + ); + const original = vw.get(); + function Sample({ + className, + suspended, + }: { + className: string; + suspended: boolean; + }) { + return ( + <> + + + {suspended ? : null} + + + ); + } + const screen = render( + , + ); + try { + await act(() => { + startTransition(() => { + screen.rerender(); + }); + return Promise.resolve(); + }); + expect(screen.getByTestId("sample").props.style.width).toBe(original / 10); + await act(() => { + vw.set(1000); + return Promise.resolve(); + }); + expect(screen.getByTestId("sample").props.style.width).toBe(100); + } finally { + screen.unmount(); + act(() => { + vw.set(original); + }); + } +}); + test("class changes detach the old rule subscription", () => { registerCSS( ".audit-rule-first { width: 10px; } .audit-rule-second { width: 20px; }", diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..d2cdd117 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -241,8 +241,7 @@ export function updateRules( // Generate a StyleObservable for this unique set of rules / variables const stylesObs = stylesFamily(generateStateHash(state, rules), rules); - // Get the guards without subscribing to the observable - // We will subscribe within the render using the StyleEffect + // Get the guards without subscribing to the observable; the style effect subscribes when the component commits guards.push(...stylesObs.get().guards); // If these are the same styles with no inline variables, we can skip the update @@ -250,9 +249,6 @@ export function updateRules( return state; } - // Remove this component from the old observer - state.stylesObs?.unsubscribe(state.styleEffect); - return { ...state, currentProps, diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index b65165e5..7243826c 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -4,6 +4,7 @@ import { Fragment, useContext, useEffect, + useLayoutEffect, useRef, useState, type ComponentType, @@ -18,6 +19,8 @@ import { cleanupEffect, ContainerContext, hasChangedDependencies, + observeReads, + recordReads, type ContainerContextValue, type Effect, type Getter, @@ -118,16 +121,30 @@ export function useNativeCss( // Distinguishes a fresh mount from an effect replay (Activity, StrictMode) const hasCommittedRef = useRef(false); + // React may discard a render, so a render subscribes nothing and its commit subscribes what it read + const styleReads = recordReads(); + + useLayoutEffect(() => { + if (observeReads(state.styleEffect, styleReads.reads) === "moved") { + state.styleEffect.run(); + } + }); + + useLayoutEffect( + () => () => { + cleanupEffect(state.styleEffect); + }, + [state.styleEffect], + ); + useEffect(() => { // Reconnect subscriptions after React replays an effect setup. if (state.ruleEffect.observers.size === 0) { if (hasCommittedRef.current || hasChangedDependencies(state.ruleEffect)) { // Replay, or conditions changed since the initializer: recalculate. state.ruleEffect.run(); - state.styleEffect.run(); } else { // Fresh mount: resubscribe to the initializer's dependencies. - // styleEffect resubscribes itself during render. for (const observable of state.ruleEffect.dependencies ?? []) { observable.get(state.ruleEffect); } @@ -136,9 +153,8 @@ export function useNativeCss( hasCommittedRef.current = true; return () => { cleanupEffect(state.ruleEffect); - cleanupEffect(state.styleEffect); }; - }, [state.ruleEffect, state.styleEffect]); + }, [state.ruleEffect]); // Check if our derived state has changed (e.g the className prop) if ( @@ -164,6 +180,7 @@ export function useNativeCss( let props = getStyledProps( state, + styleReads.read, originalProps, type === Image ? adaptImageProps : undefined, ); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index a32ac64d..2fb02722 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -20,6 +20,8 @@ export type Effect = { export type Observable = { observers: Set; + /** Advances each time the value changes, so a read can be checked later without subscribing */ + readonly version: number; get: (effect?: Effect) => Value; set: (arg: Arg) => void; run: () => void; @@ -36,46 +38,80 @@ export function observable( init: Value | Read, equality: (value1: Value, value2: Value) => boolean = Object.is, ) { - let value: Value; - let isStatic = typeof init !== "function"; - let didInit: boolean | undefined; + const isStatic = typeof init !== "function"; + let value = (isStatic ? init : undefined) as Value; + let version = 0; let lastArg: Arg | undefined; - - if (typeof init !== "function") { - value = init; - didInit = true; - } + /** What a computed value was last computed from, each source beside its version then */ + let sources: Map, number> | undefined; const observers = new Set(); const effect: Effect = { observers: new Set(), run: () => { - if (!isStatic) { - cleanupEffect(effect); - const nextValue = (init as Read)(getter, lastArg); - if (equality(value, nextValue)) { - return; - } - value = nextValue; + if (isStatic || compute(lastArg)) { + notify(); } - - notify(); }, }; - const getter: Getter = (observable) => - observable.get(observers.size > 0 ? effect : undefined); + const getter: Getter = (source) => { + const sourceValue = source.get(observers.size > 0 ? effect : undefined); + sources?.set(source, source.version); + return sourceValue; + }; + + /** Computes the value, answering whether it changed. Subscribes to the sources only while observed. */ + function compute(arg: Arg | undefined) { + cleanupEffect(effect); + const isFirstComputation = sources === undefined; + sources = new Map(); + const nextValue = (init as Read)(getter, arg); + if (!isFirstComputation && equality(value, nextValue)) { + return false; + } + value = nextValue; + version++; + return true; + } + + /** The sources of a value that is still current, bringing each computed source up to date first */ + function currentSources() { + if (sources === undefined) { + return undefined; + } + for (const [source, seenVersion] of sources) { + source.get(); + if (source.version !== seenVersion) { + return undefined; + } + } + return sources; + } + + function subscribe(subscriber: Effect) { + observers.add(subscriber); + subscriber.observers.add(obs); + } function get(subscriber?: Effect) { - if (subscriber) { - if (observers.size === 0 && !isStatic) didInit = false; - observers.add(subscriber); - subscriber.observers.add(obs); + if (isStatic) { + if (subscriber) subscribe(subscriber); + return value; } - if (!didInit) { - cleanupEffect(effect); - value = (init as Read)(getter, lastArg); - didInit = observers.size > 0; + + // While observed, a computed value is kept current by its sources + const isObserved = observers.size > 0; + if (subscriber) subscribe(subscriber); + if (!isObserved) { + const current = currentSources(); + if (current === undefined) { + compute(lastArg); + } else if (observers.size > 0) { + for (const source of current.keys()) { + source.get(effect); + } + } } return value; @@ -87,17 +123,12 @@ export function observable( return; } value = arg as unknown as Value; + version++; } else { - cleanupEffect(effect); - const nextValue = (init as Read)(getter, arg); - - didInit = observers.size > 0; lastArg = arg; - - if (equality(value, nextValue)) { + if (!compute(arg)) { return; } - value = nextValue; } notify(); @@ -117,13 +148,15 @@ export function observable( const obs: Observable = { observers, + get version() { + return version; + }, get, set, run: effect.run, unsubscribe(subscriber) { if (observers.delete(subscriber) && observers.size === 0 && !isStatic) { cleanupEffect(effect); - didInit = false; } subscriber.observers.delete(obs); }, @@ -156,6 +189,47 @@ export function hasChangedDependencies(effect: Effect): boolean { return false; } +/** What a render read, each observable beside its version when read */ +export type RenderReads = readonly (readonly [ + observable: Observable, + version: number, +])[]; + +/** A getter that subscribes nothing and records each read, for a render React may still discard */ +export function recordReads(): { read: Getter; reads: RenderReads } { + const reads: (readonly [Observable, number])[] = []; + return { + read: (observable) => { + const value = observable.get(); + reads.push([observable, observable.version]); + return value; + }, + reads, + }; +} + +/** Make `effect` observe exactly what a committed render read, answering whether any of it moved since */ +export function observeReads( + effect: Effect, + reads: RenderReads, +): "moved" | "unmoved" { + const read = new Set>(); + let moved = false; + for (const [observable, version] of reads) { + read.add(observable); + observable.get(effect); + if (observable.version !== version) { + moved = true; + } + } + for (const observable of Array.from(effect.observers)) { + if (!read.has(observable)) { + observable.unsubscribe(effect); + } + } + return moved ? "moved" : "unmoved"; +} + /** Family Helpers ************************************************************/ export function family( diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..a165830d 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -13,6 +13,7 @@ import { observable, VAR_SYMBOL, type Effect, + type Getter, type VariableContextValue, } from "../reactivity"; import { calculateProps } from "./calculate-props"; @@ -186,6 +187,7 @@ export const stylesFamily = family( export function getStyledProps( state: ComponentState, + read: Getter, inline: Record | undefined | null, adaptProps: ( props: Record | undefined, @@ -193,7 +195,7 @@ export function getStyledProps( ) { let result: Record | undefined; - const styledProps = state.stylesObs?.get(state.styleEffect); + const styledProps = state.stylesObs ? read(state.stylesObs) : undefined; // Each config merges a complete props object. Preserve its full target path // before the next config merges unrelated inline props over that object. From e8578d047c9fa7c2bbba18e86d45c477d5713d72 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 01:46:12 +0300 Subject: [PATCH 2/4] fix(native): compute a value again after its computation throws The sources a computation had read when it threw described no value, so a later read found them unchanged and returned the previous value without computing. A throwing computation now forgets its sources, and the next read computes again. --- .../native/reactivity-dependencies.test.tsx | 15 +++++++++++++++ src/native/reactivity.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx index da593772..8e9e6673 100644 --- a/src/__tests__/native/reactivity-dependencies.test.tsx +++ b/src/__tests__/native/reactivity-dependencies.test.tsx @@ -120,6 +120,21 @@ test("a computed value read without a subscriber is computed once until a source expect(compute).toHaveBeenCalledTimes(2); }); +test("a computed value whose computation threw computes again on its next read", () => { + const source = observable(0); + const value = observable((get) => { + const current = get(source); + if (current === 0) { + throw new Error("no value yet"); + } + return current * 2; + }); + expect(() => value.get()).toThrow("no value yet"); + expect(() => value.get()).toThrow("no value yet"); + source.set(5); + expect(value.get()).toBe(10); +}); + test("a read without a subscriber subscribes nothing", () => { const source = observable(10); const value = observable((get) => get(source) * 2); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 2fb02722..67084154 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -66,7 +66,14 @@ export function observable( cleanupEffect(effect); const isFirstComputation = sources === undefined; sources = new Map(); - const nextValue = (init as Read)(getter, arg); + let nextValue: Value; + try { + nextValue = (init as Read)(getter, arg); + } catch (error) { + // A computation that threw produced no value, so the next read computes again rather than trusting a partial read + sources = undefined; + throw error; + } if (!isFirstComputation && equality(value, nextValue)) { return false; } From 36dc8ad6d76e1a639d2b83dcdd20dfb1da234f30 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 10:35:09 +0300 Subject: [PATCH 3/4] fix(native): put a styles entry back when React re-runs an element's effects StrictMode's effect replay and a re-shown clean an element's effects up and run them again. The cleanup's unsubscribe drops the element's styles entry from the cache, and the commit then re-subscribes the entry the element still holds, so its hash maps to nothing. The next rule pass that keeps the element's rules finds no entry, builds another and renders; releasing the old entry then deletes the new one, which shares its hash, so every later such pass does the same. An entry that gains its first observer goes back under its hash when the hash holds nothing. --- .../native/reactivity-lifecycle.test.tsx | 69 ++++++++++++++++++- src/native/reactivity.ts | 6 ++ src/native/styles/index.ts | 11 ++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/reactivity-lifecycle.test.tsx b/src/__tests__/native/reactivity-lifecycle.test.tsx index 45ef6db5..0d47fd8e 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -1,4 +1,11 @@ -import { startTransition, StrictMode, Suspense, use } from "react"; +import { + Activity, + startTransition, + StrictMode, + Suspense, + use, + type ReactElement, +} from "react"; import { Text } from "react-native"; import { act, render, renderAsync } from "@testing-library/react-native"; @@ -10,7 +17,8 @@ import { StyleCollection, } from "react-native-css/native-internal"; -import { cleanupEffect, observable, vw } from "../../native/reactivity"; +import * as useNativeCssModule from "../../native/react/useNativeCss"; +import { cleanupEffect, observable, vh, vw } from "../../native/reactivity"; import * as calculatePropsModule from "../../native/styles/calculate-props"; test("cleaned observers receive no later notifications", () => { @@ -302,3 +310,60 @@ test("class changes detach the old rule subscription", () => { screen.unmount(); expect(second.observers.size).toBe(0); }); + +function setViewport(width: number, height: number) { + act(() => { + vw.set(width); + vh.set(height); + }); +} + +const ELEMENT_MOUNTS = [ + ["mounted once", (element: ReactElement) => render(element)], + [ + "whose effects StrictMode ran twice", + (element: ReactElement) => render({element}), + ], + [ + "that Activity hid and showed again", + (element: ReactElement) => { + const screen = render({element}); + screen.rerender({element}); + screen.rerender({element}); + return screen; + }, + ], +] as const; + +test.each(ELEMENT_MOUNTS)( + "a viewport change that moves no rule renders nothing and computes no styles on an element %s", + (_shape, mount) => { + registerCSS(` + .audit-rule-unmoved { height: 5px; } + @media (min-width: 600px) { .audit-rule-unmoved { width: 10px; } } + `); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + const screen = mount( + , + ); + const renders = jest.spyOn(useNativeCssModule, "useNativeCss"); + const computations = jest.spyOn(calculatePropsModule, "calculateProps"); + try { + act(() => { + vw.set(550); + }); + expect(renders).not.toHaveBeenCalled(); + expect(computations).not.toHaveBeenCalled(); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + }); + } finally { + computations.mockRestore(); + renders.mockRestore(); + screen.unmount(); + setViewport(originalWidth, originalHeight); + } + }, +); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 67084154..3bf0b6dc 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -254,6 +254,12 @@ export function family( delete(key: Key) { return map.delete(key); }, + getOrInsert(key: Key, value: Result) { + if (!map.has(key)) { + map.set(key, value); + } + return map.get(key) as Result; + }, clear() { return map.clear(); }, diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index a165830d..50711cf7 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -173,8 +173,17 @@ export const stylesFamily = family( /** * A family is a map, so we need to cleanup the observers when the the hash is no longer used */ - const unsubscribe = obs.unsubscribe; + const { get, unsubscribe } = obs; return Object.assign(obs, { + get: (effect?: Effect) => { + const wasUnobserved = obs.observers.size === 0; + const value = get(effect); + // React runs an element's effects again after cleaning them up (StrictMode, a re-shown ), so the entry it holds comes back + if (effect && wasUnobserved) { + stylesFamily.getOrInsert(hash, obs); + } + return value; + }, unsubscribe: (effect: Effect) => { unsubscribe(effect); if (obs.observers.size === 0) { From 1caa1c4e28f00e07bdc655f88acd152bdec58762 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 12:57:59 +0300 Subject: [PATCH 4/4] fix(native): subscribe a component's rule effect once it commits updateRules unsubscribed the rule effect from the previous rules and subscribed it to every rule and condition it read while a render ran, so a re-render React discards (a suspended transition) left a mounted element on the discarded render's rules and off the ones it still shows: a media query on the committed class stopped applying. The rule pass now reads through a recording getter, and the state it produces carries what it read. A layout effect makes the rule effect observe exactly that once the state commits, releases what it no longer reads, and runs the pass again when a read moved since. A pass that keeps the same rules keeps the state unless it read other observables. testRule takes the element's key beside the getter, since the getter no longer identifies the element. This replaces the mount effect's catch-up: a first commit, a StrictMode replay and a re-shown Activity all observe the reads the state carries, so the dependency snapshots cleanupEffect took, hasChangedDependencies and the committed flag go. The rule effect computes its update from the committed state and sets it as a value. An updater is computed eagerly from the state React last rendered, which a render it discards still writes, so after a discarded class change the update was computed from the discarded state and bailed out. A pass that changes nothing sets nothing, so a resize that moves no rule renders nothing. --- .../native/reactivity-lifecycle.test.tsx | 262 ++++++++++++++++-- src/native/conditions/index.ts | 11 +- src/native/react/rules.ts | 30 +- src/native/react/useNativeCss.ts | 57 ++-- src/native/reactivity.ts | 36 ++- 5 files changed, 302 insertions(+), 94 deletions(-) diff --git a/src/__tests__/native/reactivity-lifecycle.test.tsx b/src/__tests__/native/reactivity-lifecycle.test.tsx index 0d47fd8e..f88bf137 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -17,6 +17,7 @@ import { StyleCollection, } from "react-native-css/native-internal"; +import * as rulesModule from "../../native/react/rules"; import * as useNativeCssModule from "../../native/react/useNativeCss"; import { cleanupEffect, observable, vh, vw } from "../../native/reactivity"; import * as calculatePropsModule from "../../native/styles/calculate-props"; @@ -207,6 +208,41 @@ function NeverResolves() { return use(neverResolves); } +function SampleBesideSuspense({ + className, + suspended, +}: { + className: string; + suspended: boolean; +}) { + return ( + <> + + + {suspended ? : null} + + + ); +} + +async function renderDiscardedClassChange( + committedClassName: string, + discardedClassName: string, +) { + const screen = render( + , + ); + await act(() => { + startTransition(() => { + screen.rerender( + , + ); + }); + return Promise.resolve(); + }); + return screen; +} + test("a render React discards leaves no subscription and takes no later update", async () => { registerCSS(".audit-discarded-render { width: 10vw; }"); const original = vw.get(); @@ -256,32 +292,11 @@ test("a render React discards leaves the committed element subscribed", async () ".audit-kept-first { width: 10vw; } .audit-kept-second { width: 20px; }", ); const original = vw.get(); - function Sample({ - className, - suspended, - }: { - className: string; - suspended: boolean; - }) { - return ( - <> - - - {suspended ? : null} - - - ); - } - const screen = render( - , + const screen = await renderDiscardedClassChange( + "audit-kept-first", + "audit-kept-second", ); try { - await act(() => { - startTransition(() => { - screen.rerender(); - }); - return Promise.resolve(); - }); expect(screen.getByTestId("sample").props.style.width).toBe(original / 10); await act(() => { vw.set(1000); @@ -318,6 +333,76 @@ function setViewport(width: number, height: number) { }); } +test("a class change React discards leaves the committed element following its rules", async () => { + registerCSS(` + .audit-rule-kept-first { height: 5px; } + @media (min-width: 600px) { .audit-rule-kept-first { width: 10px; } } + .audit-rule-kept-second { width: 20px; } + `); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + const screen = await renderDiscardedClassChange( + "audit-rule-kept-first", + "audit-rule-kept-second", + ); + try { + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + }); + await act(() => { + vw.set(700); + return Promise.resolve(); + }); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + width: 10, + }); + } finally { + screen.unmount(); + setViewport(originalWidth, originalHeight); + } +}); + +test("a class change React discards leaves no subscription to the discarded rules", async () => { + registerCSS(` + .audit-rule-gone-first { width: 10px; } + @media (min-width: 600px) { .audit-rule-gone-second { width: 20px; } } + `); + const discardedRules = StyleCollection.styles("audit-rule-gone-second"); + const viewportObservers = vw.observers.size; + const screen = await renderDiscardedClassChange( + "audit-rule-gone-first", + "audit-rule-gone-second", + ); + try { + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + width: 10, + }); + expect(discardedRules.observers.size).toBe(0); + expect(vw.observers.size).toBe(viewportObservers); + } finally { + screen.unmount(); + } +}); + +test("mounting an element runs its rule pass once", () => { + registerCSS(".audit-single-rule-pass { width: 10px; }"); + const rulePasses = jest.spyOn(rulesModule, "updateRules"); + try { + const screen = render( + , + ); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + width: 10, + }); + expect(rulePasses).toHaveBeenCalledTimes(1); + screen.unmount(); + } finally { + rulePasses.mockRestore(); + } +}); + const ELEMENT_MOUNTS = [ ["mounted once", (element: ReactElement) => render(element)], [ @@ -367,3 +452,132 @@ test.each(ELEMENT_MOUNTS)( } }, ); + +test("a viewport change between render and commit reaches the element's rules once it commits", () => { + registerCSS(` + .audit-rule-window { height: 5px; } + @media (min-width: 600px) { .audit-rule-window { width: 10px; } } + `); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + const errors = captureConsoleErrors(); + function ResizeDuringRender() { + vw.set(700); + return null; + } + try { + const screen = render( + <> + + + , + ); + expect(errors.messages()).toEqual([]); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + width: 10, + }); + screen.unmount(); + } finally { + errors.restore(); + setViewport(originalWidth, originalHeight); + } +}); + +test("a rule pass that reads more after a change follows what it read", () => { + registerCSS(` + .audit-rule-reads-more { height: 5px; } + @media (min-width: 600px) and (min-height: 600px) { + .audit-rule-reads-more { width: 10px; } + } + `); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + const screen = render( + , + ); + try { + act(() => { + vw.set(700); + }); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + }); + act(() => { + vh.set(700); + }); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + width: 10, + }); + } finally { + screen.unmount(); + setViewport(originalWidth, originalHeight); + } +}); + +test("StrictMode rule consumers remain reactive and release subscriptions", () => { + registerCSS(` + .audit-strict-rule { height: 5px; } + @media (min-width: 600px) { .audit-strict-rule { width: 10px; } } + `); + const rules = StyleCollection.styles("audit-strict-rule"); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + const viewportObservers = vw.observers.size; + const screen = render( + + + , + ); + try { + act(() => { + vw.set(700); + }); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + width: 10, + }); + } finally { + screen.unmount(); + expect(rules.observers.size).toBe(0); + expect(vw.observers.size).toBe(viewportObservers); + setViewport(originalWidth, originalHeight); + } +}); + +test("an element Activity hid follows rules that changed while it was hidden", async () => { + registerCSS(` + .audit-activity-rule { height: 5px; } + @media (min-width: 600px) { .audit-activity-rule { width: 10px; } } + `); + const originalWidth = vw.get(); + const originalHeight = vh.get(); + setViewport(500, 500); + function Sample({ mode }: { mode: "visible" | "hidden" }) { + return ( + + + + ); + } + const screen = await renderAsync(); + try { + await screen.rerenderAsync(); + await act(() => { + vw.set(700); + return Promise.resolve(); + }); + await screen.rerenderAsync(); + expect(screen.getByTestId("sample").props.style).toStrictEqual({ + height: 5, + width: 10, + }); + } finally { + await screen.unmountAsync(); + setViewport(originalWidth, originalHeight); + } +}); diff --git a/src/native/conditions/index.ts b/src/native/conditions/index.ts index 8886f689..1ad028f9 100644 --- a/src/native/conditions/index.ts +++ b/src/native/conditions/index.ts @@ -15,12 +15,13 @@ import { testMediaQuery } from "./media-query"; export function testRule( rule: StyleRule, + key: WeakKey, get: Getter, props: Props, guards: RenderGuard[], containerContext: ContainerContextValue, ) { - if (rule.p && !pseudoClasses(rule.p, get)) { + if (rule.p && !pseudoClasses(rule.p, key, get)) { return false; } if (rule.m && !testMediaQuery(rule.m, get)) { @@ -39,16 +40,16 @@ export function testRule( return true; } -function pseudoClasses(query: PseudoClassesQuery, get: Getter) { +function pseudoClasses(query: PseudoClassesQuery, key: WeakKey, get: Getter) { // IMPORTANT: active needs: to be first. Modifies a global value // that we use to determine if the component should be a pressable - if (query.a && !get(activeFamily(get))) { + if (query.a && !get(activeFamily(key))) { return false; } - if (query.h && !get(hoverFamily(get))) { + if (query.h && !get(hoverFamily(key))) { return false; } - if (query.f && !get(focusFamily(get))) { + if (query.f && !get(focusFamily(key))) { return false; } return true; diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index d2cdd117..65283e2b 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -8,13 +8,15 @@ import type { RenderGuard } from "../conditions/guards"; import { getDeepPath } from "../objects"; import { activeFamily, - cleanupEffect, containerLayoutFamily, focusFamily, hoverFamily, + readSameObservables, + recordReads, VAR_SYMBOL, weakFamily, type ContainerContextValue, + type Getter, type VariableContextValue, } from "../reactivity"; import { stylesFamily } from "../styles"; @@ -31,7 +33,8 @@ export function updateRules( forceUpdate = false, isRerender = true, ): ComponentState { - cleanupEffect(state.ruleEffect); + // React may discard the render this pass runs in, so it subscribes nothing and the commit observes what it read + const ruleReads = recordReads(); const guards: RenderGuard[] = []; const rules = new Set(); if (forceUpdate) { @@ -63,9 +66,7 @@ export function updateRules( if (typeof source === "string") { const classNames = source.split(/\s+/); for (const className of classNames) { - styleRuleSet.push( - ...StyleCollection.styles(className).get(state.ruleEffect), - ); + styleRuleSet.push(...ruleReads.read(StyleCollection.styles(className))); } } @@ -84,7 +85,7 @@ export function updateRules( INLINE_RULE_SYMBOL in item && typeof item[INLINE_RULE_SYMBOL] === "string" ) { - pushInlineRule(state, item, styleRuleSet); + pushInlineRule(ruleReads.read, item, styleRuleSet); } } } else if ( @@ -98,7 +99,7 @@ export function updateRules( INLINE_RULE_SYMBOL in target && typeof target[INLINE_RULE_SYMBOL] === "string" ) { - pushInlineRule(state, target, styleRuleSet); + pushInlineRule(ruleReads.read, target, styleRuleSet); } } @@ -122,6 +123,7 @@ export function updateRules( !testRule( rule, state.ruleEffectGetter, + ruleReads.read, currentProps, guards, inheritedContainers, @@ -217,6 +219,7 @@ export function updateRules( pressable, variables, containers, + ruleReads: ruleReads.reads, }; } @@ -246,7 +249,9 @@ export function updateRules( // If these are the same styles with no inline variables, we can skip the update if (state.stylesObs === stylesObs && !inlineVariables.size) { - return state; + return readSameObservables(state.ruleReads, ruleReads.reads) + ? state + : { ...state, ruleReads: ruleReads.reads }; } return { @@ -258,6 +263,7 @@ export function updateRules( guards, animated, pressable, + ruleReads: ruleReads.reads, }; } @@ -271,13 +277,9 @@ const getRuleVariation = weakFamily((rule: StyleRule) => { }); }); -function pushInlineRule( - state: ComponentState, - item: any, - styleRuleSet: StyleRule[], -) { +function pushInlineRule(read: Getter, item: any, styleRuleSet: StyleRule[]) { for (const className of item[INLINE_RULE_SYMBOL].split(/\s+/)) { - let inlineRuleSet = StyleCollection.styles(className).get(state.ruleEffect); + let inlineRuleSet = read(StyleCollection.styles(className)); for (let rule of inlineRuleSet) { styleRuleSet.push(rule); diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index 7243826c..6a56fc0d 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -3,7 +3,6 @@ import { createElement, Fragment, useContext, - useEffect, useLayoutEffect, useRef, useState, @@ -18,12 +17,12 @@ import { testGuards, type RenderGuard } from "../conditions/guards"; import { cleanupEffect, ContainerContext, - hasChangedDependencies, observeReads, recordReads, type ContainerContextValue, type Effect, type Getter, + type RenderReads, type VariableContextValue, } from "../reactivity"; import { animatedComponentFamily } from "../reanimated"; @@ -43,6 +42,8 @@ export type ComponentState = { /** Reactive tracking */ ruleEffect: Effect; ruleEffectGetter: Getter; + /** What the rule pass that produced this state read, for the commit to observe */ + ruleReads: RenderReads; styleEffect: Effect; /** The components props */ @@ -82,7 +83,14 @@ export function useNativeCss( */ const ruleEffect: Effect = { observers: new Set(), - run: () => setState((state) => updateRules(state)), + run: () => { + // Not an updater: React computes one eagerly from its last rendered state, which a render it discards still writes + const committed = committedState.current; + const next = updateRules(committed); + if (next !== committed) { + setState(next); + } + }, }; /** @@ -96,10 +104,11 @@ export function useNativeCss( run: () => setState((state) => ({ ...state })), }; - const initialState = updateRules( + return updateRules( { ruleEffect, ruleEffectGetter: (observable) => observable.get(ruleEffect), + ruleReads: [], styleEffect, configs, inheritedContainers, @@ -112,18 +121,22 @@ export function useNativeCss( false, false, ); - // State initializers may be discarded by React StrictMode. Subscribe once - // the component commits instead of retaining an abandoned initializer. - cleanupEffect(ruleEffect); - return initialState; }); - - // Distinguishes a fresh mount from an effect replay (Activity, StrictMode) - const hasCommittedRef = useRef(false); + const committedState = useRef(state); // React may discard a render, so a render subscribes nothing and its commit subscribes what it read const styleReads = recordReads(); + useLayoutEffect(() => { + committedState.current = state; + }); + + useLayoutEffect(() => { + if (observeReads(state.ruleEffect, state.ruleReads) === "moved") { + state.ruleEffect.run(); + } + }, [state.ruleEffect, state.ruleReads]); + useLayoutEffect(() => { if (observeReads(state.styleEffect, styleReads.reads) === "moved") { state.styleEffect.run(); @@ -132,30 +145,12 @@ export function useNativeCss( useLayoutEffect( () => () => { + cleanupEffect(state.ruleEffect); cleanupEffect(state.styleEffect); }, - [state.styleEffect], + [state.ruleEffect, state.styleEffect], ); - useEffect(() => { - // Reconnect subscriptions after React replays an effect setup. - if (state.ruleEffect.observers.size === 0) { - if (hasCommittedRef.current || hasChangedDependencies(state.ruleEffect)) { - // Replay, or conditions changed since the initializer: recalculate. - state.ruleEffect.run(); - } else { - // Fresh mount: resubscribe to the initializer's dependencies. - for (const observable of state.ruleEffect.dependencies ?? []) { - observable.get(state.ruleEffect); - } - } - } - hasCommittedRef.current = true; - return () => { - cleanupEffect(state.ruleEffect); - }; - }, [state.ruleEffect]); - // Check if our derived state has changed (e.g the className prop) if ( testGuards(state, originalProps, inheritedVariables, inheritedContainers) diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 3bf0b6dc..f8a050ab 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -12,10 +12,6 @@ import type { StyleDescriptor } from "react-native-css/compiler"; export type Effect = { observers: Set>; run(): void; - /** Dependencies recorded by the last cleanupEffect() */ - dependencies?: Observable[]; - /** Dependency values recorded by the last cleanupEffect() */ - snapshots?: [Observable, unknown][]; }; export type Observable = { @@ -174,28 +170,13 @@ export function observable( export function cleanupEffect(effect: Effect) { if (!effect) return; - const dependencies = (effect.dependencies = Array.from(effect.observers)); - // Snapshot before detaching so computed observables return cached values - effect.snapshots = dependencies.map( - (observable) => - [observable, observable.get()] as [Observable, unknown], - ); + const dependencies = Array.from(effect.observers); effect.observers.clear(); for (const dep of dependencies) { dep.unsubscribe(effect); } } -/** Whether any of the effect's dependencies changed since cleanupEffect() */ -export function hasChangedDependencies(effect: Effect): boolean { - const snapshots = effect.snapshots; - if (!snapshots) return false; - for (const [observable, value] of snapshots) { - if (!Object.is(observable.get(), value)) return true; - } - return false; -} - /** What a render read, each observable beside its version when read */ export type RenderReads = readonly (readonly [ observable: Observable, @@ -215,6 +196,21 @@ export function recordReads(): { read: Getter; reads: RenderReads } { }; } +/** Whether two passes read the same observables, whatever their versions */ +export function readSameObservables(left: RenderReads, right: RenderReads) { + const leftObservables = new Set(left.map(([observable]) => observable)); + const rightObservables = new Set(right.map(([observable]) => observable)); + if (leftObservables.size !== rightObservables.size) { + return false; + } + for (const observable of leftObservables) { + if (!rightObservables.has(observable)) { + return false; + } + } + return true; +} + /** Make `effect` observe exactly what a committed render read, answering whether any of it moved since */ export function observeReads( effect: Effect,