diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx index 52d4cd3b..8e9e6673 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,134 @@ 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 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); + 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..0d47fd8e 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -1,7 +1,14 @@ -import { StrictMode } from "react"; +import { + Activity, + startTransition, + StrictMode, + Suspense, + use, + type ReactElement, +} 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"; @@ -10,7 +17,9 @@ 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", () => { const value = observable(0); @@ -156,6 +165,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; }", @@ -170,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/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..3bf0b6dc 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,87 @@ 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(); + 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; + } + 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 +130,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 +155,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 +196,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( @@ -173,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 6be03daa..50711cf7 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"; @@ -172,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) { @@ -186,6 +196,7 @@ export const stylesFamily = family( export function getStyledProps( state: ComponentState, + read: Getter, inline: Record | undefined | null, adaptProps: ( props: Record | undefined, @@ -193,7 +204,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.