From 857b07ebb751b061b39c45a5ceba013f035f6153 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 12:53:47 +0300 Subject: [PATCH 1/3] fix(native): subscribe the style effect to what a committed render read A render subscribed the component's style effect to every observable it read, and updateRules unsubscribed it from the previous styles, so a render React discards (a suspended transition, a Suspense fallback) left the effect on the observables of a component that never mounted. Every later change to those styles ran that component's effect ("Can't perform a React state update on a component that hasn't mounted yet"), and the discarded render could detach a mounted component from the styles it still shows. A render now reads through a getter that subscribes nothing and records each read beside the observable's version, and getStyledProps takes that getter, so every read obeys the same gate. After every commit the style effect observes exactly what that render read, releases what it no longer reads, and re-renders when a read moved since the render. Observables carry a version, and a computed value keeps the sources it was computed from with their versions, so a read without a subscriber is served from cache until a source changes, and a first subscriber keeps the value instead of computing it again. --- .../native/reactivity-dependencies.test.tsx | 121 ++++++++++++++- .../native/reactivity-lifecycle.test.tsx | 136 ++++++++++++++++- src/native/react/rules.ts | 6 +- src/native/react/useNativeCss.ts | 25 ++- src/native/reactivity.ts | 144 +++++++++++++----- src/native/styles/index.ts | 4 +- 6 files changed, 388 insertions(+), 48 deletions(-) diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx index 52d4cd3b..da593772 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,119 @@ 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 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..45ef6db5 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from "react"; +import { startTransition, StrictMode, Suspense, use } 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"; @@ -11,6 +11,7 @@ import { } from "react-native-css/native-internal"; import { cleanupEffect, observable, 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 +157,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; }", 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..2fb02722 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,80 @@ 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(); + const nextValue = (init as Read)(getter, arg); + 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 +123,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 +148,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 +189,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( diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..a165830d 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"; @@ -186,6 +187,7 @@ export const stylesFamily = family( export function getStyledProps( state: ComponentState, + read: Getter, inline: Record | undefined | null, adaptProps: ( props: Record | undefined, @@ -193,7 +195,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. From e8578d047c9fa7c2bbba18e86d45c477d5713d72 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 01:46:12 +0300 Subject: [PATCH 2/3] fix(native): compute a value again after its computation throws The sources a computation had read when it threw described no value, so a later read found them unchanged and returned the previous value without computing. A throwing computation now forgets its sources, and the next read computes again. --- .../native/reactivity-dependencies.test.tsx | 15 +++++++++++++++ src/native/reactivity.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx index da593772..8e9e6673 100644 --- a/src/__tests__/native/reactivity-dependencies.test.tsx +++ b/src/__tests__/native/reactivity-dependencies.test.tsx @@ -120,6 +120,21 @@ test("a computed value read without a subscriber is computed once until a source 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); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 2fb02722..67084154 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -66,7 +66,14 @@ export function observable( cleanupEffect(effect); const isFirstComputation = sources === undefined; sources = new Map(); - const nextValue = (init as Read)(getter, arg); + 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; } From 36dc8ad6d76e1a639d2b83dcdd20dfb1da234f30 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 10:35:09 +0300 Subject: [PATCH 3/3] fix(native): put a styles entry back when React re-runs an element's effects StrictMode's effect replay and a re-shown clean an element's effects up and run them again. The cleanup's unsubscribe drops the element's styles entry from the cache, and the commit then re-subscribes the entry the element still holds, so its hash maps to nothing. The next rule pass that keeps the element's rules finds no entry, builds another and renders; releasing the old entry then deletes the new one, which shares its hash, so every later such pass does the same. An entry that gains its first observer goes back under its hash when the hash holds nothing. --- .../native/reactivity-lifecycle.test.tsx | 69 ++++++++++++++++++- src/native/reactivity.ts | 6 ++ src/native/styles/index.ts | 11 ++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/reactivity-lifecycle.test.tsx b/src/__tests__/native/reactivity-lifecycle.test.tsx index 45ef6db5..0d47fd8e 100644 --- a/src/__tests__/native/reactivity-lifecycle.test.tsx +++ b/src/__tests__/native/reactivity-lifecycle.test.tsx @@ -1,4 +1,11 @@ -import { startTransition, StrictMode, Suspense, use } from "react"; +import { + Activity, + startTransition, + StrictMode, + Suspense, + use, + type ReactElement, +} from "react"; import { Text } from "react-native"; import { act, render, renderAsync } from "@testing-library/react-native"; @@ -10,7 +17,8 @@ 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", () => { @@ -302,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/reactivity.ts b/src/native/reactivity.ts index 67084154..3bf0b6dc 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -254,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 a165830d..50711cf7 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -173,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) {