Skip to content

Commit eda2b28

Browse files
sammooreclaude
andauthored
perf: avoid calculating styles/rules twice on first mount (#466)
* perf: avoid calculating ruleEffect/styleEffect twice on fresh mounts * fix: pre-render/unmounted effects can use stale rules/styles * chore: clean up llm comments in test * perf: remove redundant call to stylesObs?.get * chore: simplify comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a5002c5 commit eda2b28

3 files changed

Lines changed: 203 additions & 3 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { Activity } from "react";
2+
3+
import { act, render } from "@testing-library/react-native";
4+
import { View } from "react-native-css/components/View";
5+
import { registerCSS, testID } from "react-native-css/jest";
6+
import { colorScheme } from "react-native-css/runtime";
7+
8+
/**
9+
* <Activity mode="hidden"> renders children without mounting effects, so
10+
* conditions may change between useNativeCss's state initializer and its
11+
* first effect setup.
12+
*/
13+
14+
function Fixture({ className }: { className: string }) {
15+
return <View testID={testID} className={className} />;
16+
}
17+
18+
function ActivityFixture({
19+
hidden,
20+
className,
21+
}: {
22+
hidden: boolean;
23+
className: string;
24+
}) {
25+
return (
26+
<Activity mode={hidden ? "hidden" : "visible"}>
27+
<Fixture className={className} />
28+
</Activity>
29+
);
30+
}
31+
32+
test("pre-rendered hidden Activity shows current styles on first show and stays reactive", () => {
33+
registerCSS(`
34+
.audit-activity-prerender { color: blue; }
35+
36+
@media (prefers-color-scheme: dark) {
37+
.audit-activity-prerender { color: red; }
38+
}
39+
`);
40+
41+
const screen = render(
42+
<ActivityFixture hidden className="audit-activity-prerender" />,
43+
);
44+
45+
// Effects mount for the first time
46+
screen.rerender(
47+
<ActivityFixture hidden={false} className="audit-activity-prerender" />,
48+
);
49+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
50+
color: "#00f",
51+
});
52+
53+
// Subscriptions are live
54+
act(() => {
55+
colorScheme.set("dark");
56+
});
57+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
58+
color: "#f00",
59+
});
60+
61+
screen.unmount();
62+
});
63+
64+
test("applies condition changes made while pre-rendered hidden, before first show", () => {
65+
registerCSS(`
66+
.audit-activity-stale { color: blue; }
67+
68+
@media (prefers-color-scheme: dark) {
69+
.audit-activity-stale { color: red; }
70+
}
71+
`);
72+
73+
const screen = render(
74+
<ActivityFixture hidden className="audit-activity-stale" />,
75+
);
76+
77+
// Change conditions after the initializer, before effects mount
78+
act(() => {
79+
colorScheme.set("dark");
80+
});
81+
82+
screen.rerender(
83+
<ActivityFixture hidden={false} className="audit-activity-stale" />,
84+
);
85+
86+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
87+
color: "#f00",
88+
});
89+
90+
screen.unmount();
91+
});
92+
93+
test("unrelated condition changes during the pre-render window do not force a catch-up render", () => {
94+
registerCSS(`
95+
.audit-activity-unrelated { color: blue; }
96+
`);
97+
98+
// colorScheme is not a dependency, so changing it should not force a re-render
99+
let renders = 0;
100+
function CountingFixture({ className }: { className: string }) {
101+
renders++;
102+
return <View testID={testID} className={className} />;
103+
}
104+
105+
const screen = render(
106+
<Activity mode="hidden">
107+
<CountingFixture className="audit-activity-unrelated" />
108+
</Activity>,
109+
);
110+
111+
expect(renders).toBe(1);
112+
113+
act(() => {
114+
colorScheme.set("dark");
115+
});
116+
117+
screen.rerender(
118+
<Activity mode="visible">
119+
<CountingFixture className="audit-activity-unrelated" />
120+
</Activity>,
121+
);
122+
123+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
124+
color: "#00f",
125+
});
126+
127+
// No extra catch-up render
128+
expect(renders).toBe(2);
129+
130+
screen.unmount();
131+
});
132+
133+
test("applies condition changes made while hidden, after a visible mount (mid-life replay)", () => {
134+
registerCSS(`
135+
.audit-activity-midlife { color: blue; }
136+
137+
@media (prefers-color-scheme: dark) {
138+
.audit-activity-midlife { color: red; }
139+
}
140+
`);
141+
142+
const screen = render(
143+
<ActivityFixture hidden={false} className="audit-activity-midlife" />,
144+
);
145+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
146+
color: "#00f",
147+
});
148+
149+
// Hiding keeps state but cleans up effects
150+
screen.rerender(
151+
<ActivityFixture hidden className="audit-activity-midlife" />,
152+
);
153+
154+
act(() => {
155+
colorScheme.set("dark");
156+
});
157+
158+
screen.rerender(
159+
<ActivityFixture hidden={false} className="audit-activity-midlife" />,
160+
);
161+
expect(screen.getByTestId(testID).props.style).toStrictEqual({
162+
color: "#f00",
163+
});
164+
165+
screen.unmount();
166+
});

‎src/native/react/useNativeCss.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Fragment,
55
useContext,
66
useEffect,
7+
useRef,
78
useState,
89
type ComponentType,
910
} from "react";
@@ -16,6 +17,7 @@ import { testGuards, type RenderGuard } from "../conditions/guards";
1617
import {
1718
cleanupEffect,
1819
ContainerContext,
20+
hasChangedDependencies,
1921
type ContainerContextValue,
2022
type Effect,
2123
type Getter,
@@ -113,12 +115,25 @@ export function useNativeCss(
113115
return initialState;
114116
});
115117

118+
// Distinguishes a fresh mount from an effect replay (Activity, StrictMode)
119+
const hasCommittedRef = useRef(false);
120+
116121
useEffect(() => {
117122
// Reconnect subscriptions after React replays an effect setup.
118123
if (state.ruleEffect.observers.size === 0) {
119-
state.ruleEffect.run();
120-
state.styleEffect.run();
124+
if (hasCommittedRef.current || hasChangedDependencies(state.ruleEffect)) {
125+
// Replay, or conditions changed since the initializer: recalculate.
126+
state.ruleEffect.run();
127+
state.styleEffect.run();
128+
} else {
129+
// Fresh mount: resubscribe to the initializer's dependencies.
130+
// styleEffect resubscribes itself during render.
131+
for (const observable of state.ruleEffect.dependencies ?? []) {
132+
observable.get(state.ruleEffect);
133+
}
134+
}
121135
}
136+
hasCommittedRef.current = true;
122137
return () => {
123138
cleanupEffect(state.ruleEffect);
124139
cleanupEffect(state.styleEffect);

‎src/native/reactivity.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import type { StyleDescriptor } from "react-native-css/compiler";
1212
export type Effect = {
1313
observers: Set<Observable<any, any>>;
1414
run(): void;
15+
/** Dependencies recorded by the last cleanupEffect() */
16+
dependencies?: Observable<any, any>[];
17+
/** Dependency values recorded by the last cleanupEffect() */
18+
snapshots?: [Observable<any, any>, unknown][];
1519
};
1620

1721
export type Observable<Value, Arg = Value> = {
@@ -130,13 +134,28 @@ export function observable<Value, Arg = Value>(
130134

131135
export function cleanupEffect(effect: Effect) {
132136
if (!effect) return;
133-
const dependencies = Array.from(effect.observers);
137+
const dependencies = (effect.dependencies = Array.from(effect.observers));
138+
// Snapshot before detaching so computed observables return cached values
139+
effect.snapshots = dependencies.map(
140+
(observable) =>
141+
[observable, observable.get()] as [Observable<any, any>, unknown],
142+
);
134143
effect.observers.clear();
135144
for (const dep of dependencies) {
136145
dep.unsubscribe(effect);
137146
}
138147
}
139148

149+
/** Whether any of the effect's dependencies changed since cleanupEffect() */
150+
export function hasChangedDependencies(effect: Effect): boolean {
151+
const snapshots = effect.snapshots;
152+
if (!snapshots) return false;
153+
for (const [observable, value] of snapshots) {
154+
if (!Object.is(observable.get(), value)) return true;
155+
}
156+
return false;
157+
}
158+
140159
/** Family Helpers ************************************************************/
141160

142161
export function family<Key, Result = Key, Args extends any = void>(

0 commit comments

Comments
 (0)