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..f88bf137 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,10 @@ import { StyleCollection, } from "react-native-css/native-internal"; -import { cleanupEffect, observable, vw } from "../../native/reactivity"; +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"; test("cleaned observers receive no later notifications", () => { const value = observable(0); @@ -156,6 +166,151 @@ 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); +} + +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(); + 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(); + const screen = await renderDiscardedClassChange( + "audit-kept-first", + "audit-kept-second", + ); + try { + 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 +325,259 @@ 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); + }); +} + +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)], + [ + "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); + } + }, +); + +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 e0fe8d1a..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, }; } @@ -241,18 +244,16 @@ 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 if (state.stylesObs === stylesObs && !inlineVariables.size) { - return state; + return readSameObservables(state.ruleReads, ruleReads.reads) + ? state + : { ...state, ruleReads: ruleReads.reads }; } - // Remove this component from the old observer - state.stylesObs?.unsubscribe(state.styleEffect); - return { ...state, currentProps, @@ -262,6 +263,7 @@ export function updateRules( guards, animated, pressable, + ruleReads: ruleReads.reads, }; } @@ -275,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 b65165e5..6a56fc0d 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -3,7 +3,7 @@ import { createElement, Fragment, useContext, - useEffect, + useLayoutEffect, useRef, useState, type ComponentType, @@ -17,10 +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"; @@ -40,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 */ @@ -79,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); + } + }, }; /** @@ -93,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, @@ -109,36 +121,35 @@ 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; }); + const committedState = useRef(state); - // Distinguishes a fresh mount from an effect replay (Activity, StrictMode) - const hasCommittedRef = useRef(false); - - 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); - } - } + // 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(); } - hasCommittedRef.current = true; - return () => { + }, [state.ruleEffect, state.ruleReads]); + + useLayoutEffect(() => { + if (observeReads(state.styleEffect, styleReads.reads) === "moved") { + state.styleEffect.run(); + } + }); + + useLayoutEffect( + () => () => { cleanupEffect(state.ruleEffect); cleanupEffect(state.styleEffect); - }; - }, [state.ruleEffect, state.styleEffect]); + }, + [state.ruleEffect, state.styleEffect], + ); // Check if our derived state has changed (e.g the className prop) if ( @@ -164,6 +175,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..f8a050ab 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -12,14 +12,12 @@ 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 = { 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 +34,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 +126,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 +151,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); }, @@ -134,26 +170,67 @@ 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; +/** 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, + }; +} + +/** 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, + 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 false; + return moved ? "moved" : "unmoved"; } /** Family Helpers ************************************************************/ @@ -173,6 +250,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.