From 46bf55367d25ac40c9fe09c1e8418946511d9d1d Mon Sep 17 00:00:00 2001 From: Sam Moore Date: Thu, 1 Oct 2026 19:59:45 -0400 Subject: [PATCH 1/4] perf: avoid calculating ruleEffect/styleEffect twice on fresh mounts --- src/native/react/useNativeCss.ts | 28 +++++++++++++++++++++++++--- src/native/reactivity.ts | 12 +++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index f38c77c2..61379088 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -4,6 +4,7 @@ import { Fragment, useContext, useEffect, + useRef, useState, type ComponentType, } from "react"; @@ -109,16 +110,37 @@ export function useNativeCss( ); // State initializers may be discarded by React StrictMode. Subscribe once // the component commits instead of retaining an abandoned initializer. + // cleanupEffect records the dependency set on the effect so the commit + // effect below can replay subscriptions without a full rule re-pass. cleanupEffect(ruleEffect); return initialState; }); + // PERF: distinguishes a fresh mount (first effect setup) from a mid-life + // replay (React hide/show, StrictMode effect re-invocation). + const hasCommittedRef = useRef(false); + useEffect(() => { - // Reconnect subscriptions after React replays an effect setup. + // Reconnect subscriptions after the initializer's (or a prior replay's) + // cleanupEffect detached them. if (state.ruleEffect.observers.size === 0) { - state.ruleEffect.run(); - state.styleEffect.run(); + if (hasCommittedRef.current) { + // Mid-life replay: conditions (media/container/interaction state) + // may have changed while unsubscribed. Re-run rule matching and + // force a catch-up render. This path is rare. + state.ruleEffect.run(); + state.styleEffect.run(); + } else { + // Fresh mount: the initializer evaluated current conditions moments + // ago. Cheaply replay the recorded dependencies instead of re-running + // a full updateRules pass and forcing a second render per mount. + for (const observable of state.ruleEffect.dependencies ?? []) { + observable.get(state.ruleEffect); + } + state.stylesObs?.get(state.styleEffect); + } } + hasCommittedRef.current = true; return () => { cleanupEffect(state.ruleEffect); cleanupEffect(state.styleEffect); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..6649a925 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -12,6 +12,12 @@ import type { StyleDescriptor } from "react-native-css/compiler"; export type Effect = { observers: Set>; run(): void; + /** + * PERF: the dependency set recorded by the most recent cleanupEffect(). + * Lets the owning component replay subscriptions cheaply instead of + * re-running a full rule pass (see useNativeCss's reconnect effect). + */ + dependencies?: Observable[]; }; export type Observable = { @@ -130,7 +136,11 @@ export function observable( export function cleanupEffect(effect: Effect) { if (!effect) return; - const dependencies = Array.from(effect.observers); + // PERF: record the dependency set before detaching. Every effect setup is + // immediately preceded by a cleanupEffect call on that effect (the state + // initializer's discard or React's cleanup-then-setup replay), so this is + // always the current set — see useNativeCss's reconnect effect. + const dependencies = (effect.dependencies = Array.from(effect.observers)); effect.observers.clear(); for (const dep of dependencies) { dep.unsubscribe(effect); From 7566ecf57c9973bc63f332888065146ef5dd20e7 Mon Sep 17 00:00:00 2001 From: Sam Moore Date: Thu, 1 Oct 2026 21:08:36 -0400 Subject: [PATCH 2/4] fix: pre-render/unmounted effects can use stale rules/styles --- .../native/reactivity-activity.test.tsx | 196 ++++++++++++++++++ src/native/react/useNativeCss.ts | 19 +- src/native/reactivity.ts | 40 ++++ 3 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/native/reactivity-activity.test.tsx diff --git a/src/__tests__/native/reactivity-activity.test.tsx b/src/__tests__/native/reactivity-activity.test.tsx new file mode 100644 index 00000000..b53b2b09 --- /dev/null +++ b/src/__tests__/native/reactivity-activity.test.tsx @@ -0,0 +1,196 @@ +import { Activity } from "react"; + +import { act, render } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +/** + * lifecycle regression tests for the subscription reconnect in + * useNativeCss. + * + * React 19.2's renders its children but does not + * mount their effects. When the boundary becomes visible, React re-renders + * the children, re-creates their effects, and — critically — does NOT re-run + * any state initializers. useNativeCss's state initializer (which runs + * updateRules and subscribes ruleEffect) therefore runs at a different time + * than the first effect setup, and observable conditions may have changed in + * between. + * + * The reconnect effect in useNativeCss must re-establish subscriptions + * against CURRENT observable state at the moment effects (re-)mount. + */ + +function Fixture({ className }: { className: string }) { + return ; +} + +function ActivityFixture({ + hidden, + className, +}: { + hidden: boolean; + className: string; +}) { + return ( + + + + ); +} + +test("pre-rendered hidden Activity shows current styles on first show and stays reactive", () => { + registerCSS(` +.audit-activity-prerender { color: blue; } + +@media (prefers-color-scheme: dark) { + .audit-activity-prerender { color: red; } +} +`); + + const screen = render( +