Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/__tests__/native/reactivity-activity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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";

/**
* <Activity> lifecycle regression tests for the subscription reconnect in
* useNativeCss.
*
* React 19.2's <Activity mode="hidden"> 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 <View testID={testID} className={className} />;
}

function ActivityFixture({
hidden,
className,
}: {
hidden: boolean;
className: string;
}) {
return (
<Activity mode={hidden ? "hidden" : "visible"}>
<Fixture className={className} />
</Activity>
);
}

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(
<ActivityFixture hidden className="audit-activity-prerender" />,
);

// Show: effects mount for the first time. Nothing changed while hidden,
// so the initial styles must be correct...
screen.rerender(
<ActivityFixture hidden={false} className="audit-activity-prerender" />,
);
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#00f",
});

// ...and the re-established subscriptions must be live, not just initially
// correct.
act(() => {
colorScheme.set("dark");
});
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#f00",
});

screen.unmount();
});

test("applies condition changes made while pre-rendered hidden, before first show", () => {
registerCSS(`
.audit-activity-stale { color: blue; }

@media (prefers-color-scheme: dark) {
.audit-activity-stale { color: red; }
}
`);

const screen = render(
<ActivityFixture hidden className="audit-activity-stale" />,
);

// The state initializer ran during the hidden render (matching rules
// against the light scheme). Conditions change AFTER the initializer but
// BEFORE the first effect mount.
act(() => {
colorScheme.set("dark");
});

screen.rerender(
<ActivityFixture hidden={false} className="audit-activity-stale" />,
);

// The first commit must re-evaluate rules against current conditions —
// not the conditions captured by the initializer.
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#f00",
});

screen.unmount();
});

test("unrelated condition changes during the pre-render window do not force a catch-up render", () => {
registerCSS(`
.audit-activity-unrelated { color: blue; }
`);

// This component has no conditional rules, so the color scheme observable
// is NOT among its recorded dependencies. Flipping it while the boundary
// is hidden is unrelated observable activity inside the initializer→commit
// window. The fresh-mount reconnect should stay on the cheap replay path.
let renders = 0;
function CountingFixture({ className }: { className: string }) {
renders++;
return <View testID={testID} className={className} />;
}

const screen = render(
<Activity mode="hidden">
<CountingFixture className="audit-activity-unrelated" />
</Activity>,
);

expect(renders).toBe(1);

act(() => {
colorScheme.set("dark");
});

screen.rerender(
<Activity mode="visible">
<CountingFixture className="audit-activity-unrelated" />
</Activity>,
);

// Styles are still resolved correctly through the replayed subscriptions.
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#00f",
});

// Exactly one render pass for the show — no forced catch-up render.
expect(renders).toBe(2);

screen.unmount();
});

test("applies condition changes made while hidden, after a visible mount (mid-life replay)", () => {
registerCSS(`
.audit-activity-midlife { color: blue; }

@media (prefers-color-scheme: dark) {
.audit-activity-midlife { color: red; }
}
`);

const screen = render(
<ActivityFixture hidden={false} className="audit-activity-midlife" />,
);
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#00f",
});

// Hiding preserves state but unmounts effects (detaching subscriptions).
screen.rerender(
<ActivityFixture hidden className="audit-activity-midlife" />,
);

act(() => {
colorScheme.set("dark");
});

// Showing re-mounts effects. The reconnect must catch up on changes that
// happened while the component was unsubscribed.
screen.rerender(
<ActivityFixture hidden={false} className="audit-activity-midlife" />,
);
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#f00",
});

screen.unmount();
});
37 changes: 34 additions & 3 deletions src/native/react/useNativeCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Fragment,
useContext,
useEffect,
useRef,
useState,
type ComponentType,
} from "react";
Expand All @@ -19,6 +20,7 @@ import {
type ContainerContextValue,
type Effect,
type Getter,
hasChangedDependencies,
type VariableContextValue,
} from "../reactivity";
import { animatedComponentFamily } from "../reanimated";
Expand Down Expand Up @@ -109,16 +111,45 @@ 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 <Activity> 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 || hasChangedDependencies(state.ruleEffect)) {
// Mid-life replay, or a condition this component's rule matching
// actually read changed between the initializer and this commit —
// e.g. a pre-rendered hidden <Activity> whose color scheme, window
// dimensions, or container layout changed before first show. Re-run
// rule matching against current conditions and force a catch-up
// render. These paths are rare.
state.ruleEffect.run();
state.styleEffect.run();
} else {
// Fresh mount with unchanged conditions: the initializer evaluated
// current conditions moments ago in the same commit. Cheaply replay
// the recorded dependencies instead of re-running a full updateRules
// pass and forcing a second render per mount.
//
// Only ruleEffect needs replaying: its dependency subscriptions are
// established exclusively by updateRules, which won't run again until
// the className or inherited props change. styleEffect's subscription
// to stylesObs is re-established by getStyledProps on every render.
for (const observable of state.ruleEffect.dependencies ?? []) {
observable.get(state.ruleEffect);
}
}
}
hasCommittedRef.current = true;
return () => {
cleanupEffect(state.ruleEffect);
cleanupEffect(state.styleEffect);
Expand Down
52 changes: 51 additions & 1 deletion src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import type { StyleDescriptor } from "react-native-css/compiler";
export type Effect = {
observers: Set<Observable<any, any>>;
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<any, any>[];
/**
* PERF: [observable, value-at-detach] pairs recorded by the most recent
* cleanupEffect(). Lets useNativeCss's fresh-mount reconnect detect
* whether any condition the component's rule matching actually read has
* changed between the initializer and the commit effect (see
* hasChangedDependencies).
*/
snapshots?: [Observable<any, any>, unknown][];
};

export type Observable<Value, Arg = Value> = {
Expand Down Expand Up @@ -130,13 +144,49 @@ export function observable<Value, Arg = Value>(

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));
// PERF: capture each dependency's current value BEFORE detaching. While
// this effect is still subscribed, computed observables return their
// cached value (didInit is true), so this is O(deps) cheap reads, not
// recomputations. Used by hasChangedDependencies.
effect.snapshots = dependencies.map(
(observable) =>
[observable, observable.get()] as [Observable<any, any>, unknown],
);
effect.observers.clear();
for (const dep of dependencies) {
dep.unsubscribe(effect);
}
}

/**
* PERF: whether any dependency recorded by the most recent cleanupEffect()
* changed value since it was detached.
*
* Scoping to the effect's own dependencies is what keeps the fresh-mount
* fast path fast: unrelated observable activity elsewhere in the app
* (another component's layout, interaction, or theme change) cannot cause
* a false positive the way a global change counter would.
*
* The reads do not subscribe. A computed observable whose last subscriber
* was just detached will recompute on read (didInit was reset by
* unsubscribe), which is no more work than the full reconnect would have
* performed anyway. Returns false when there is nothing to compare (an
* effect that never subscribed, or one whose deps are unchanged).
*/
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;
}

/** Family Helpers ************************************************************/

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