Skip to content

Commit 6430168

Browse files
committed
Merge branch 'main' into feature/structural-sharing-cache-pr-5-standalone
2 parents e6e917b + a23f03c commit 6430168

4 files changed

Lines changed: 159 additions & 8 deletions

File tree

lib/useOnyx.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
112112
// explicit reset logic — eliminating the race condition where cleanup could clobber a boolean flag.
113113
const connectedKeyRef = useRef<OnyxKey | null>(null);
114114

115+
// Tracks whether the hook has completed its initial mount subscription.
116+
// Unlike connectedKeyRef (which gets nulled by cleanup), this persists across re-subscriptions.
117+
const hasMountedRef = useRef(false);
118+
115119
// Indicates if the hook is connecting to an Onyx key.
116120
const isConnectingRef = useRef(false);
117121

@@ -257,12 +261,19 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
257261
(onStoreChange: () => void) => {
258262
// Reset internal state so the hook properly transitions through loading
259263
// for the new key instead of preserving stale state from the previous one.
260-
previousValueRef.current = null;
261-
newValueRef.current = null;
264+
// Only reset when the key has actually changed (not on initial mount).
265+
if (hasMountedRef.current) {
266+
previousValueRef.current = null;
267+
newValueRef.current = null;
268+
sourceValueRef.current = undefined;
269+
resultRef.current = [undefined, {status: options?.initWithStoredValues === false ? 'loaded' : 'loading'}];
270+
}
271+
// Force a cache re-read on every (re)subscription so any side effects from
272+
// subscribeToKey (e.g. addNullishStorageKey for skippable collection member ids)
273+
// are reflected in the next getSnapshot. Resetting this flag does not change
274+
// resultRef by itself, so it doesn't cause an extra mount render.
262275
shouldGetCachedValueRef.current = true;
263-
sourceValueRef.current = undefined;
264-
resultRef.current = [undefined, {status: options?.initWithStoredValues === false ? 'loaded' : 'loading'}];
265-
276+
hasMountedRef.current = true;
266277
isConnectingRef.current = true;
267278
onStoreChangeFnRef.current = onStoreChange;
268279

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-onyx",
3-
"version": "3.0.70",
3+
"version": "3.0.71",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

tests/unit/useOnyxTest.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,4 +1321,144 @@ describe('useOnyx', () => {
13211321
expect(result.current[1].status).toEqual('loaded');
13221322
});
13231323
});
1324+
1325+
// Regression coverage for Expensify/App#87850 ("[Onyx] Fix extra mount render introduced in useOnyx v3.0.59").
1326+
// The bug: `subscribe` unconditionally reset `resultRef.current` to a fresh tuple, including on initial mount.
1327+
// `useSyncExternalStore` then observed a different snapshot reference post-subscribe and scheduled an extra
1328+
// render per `useOnyx` hook. The fix guards the reset behind `hasMountedRef` so it only runs on re-subscription.
1329+
describe('initial mount render count', () => {
1330+
it('should render only once when the key has a value already in Onyx cache', async () => {
1331+
await Onyx.set(ONYXKEYS.TEST_KEY, 'cached_value');
1332+
1333+
let renderCount = 0;
1334+
const {result} = renderHook(() => {
1335+
renderCount++;
1336+
return useOnyx(ONYXKEYS.TEST_KEY);
1337+
});
1338+
1339+
await act(async () => waitForPromisesToResolve());
1340+
1341+
expect(result.current[0]).toEqual('cached_value');
1342+
expect(result.current[1].status).toEqual('loaded');
1343+
// A single render — no extra render caused by subscribe resetting state on initial mount.
1344+
expect(renderCount).toBe(1);
1345+
});
1346+
1347+
it('should render exactly twice (loading → loaded) when the key is not cached', async () => {
1348+
let renderCount = 0;
1349+
const {result} = renderHook(() => {
1350+
renderCount++;
1351+
return useOnyx(ONYXKEYS.TEST_KEY);
1352+
});
1353+
1354+
await act(async () => waitForPromisesToResolve());
1355+
1356+
expect(result.current[0]).toBeUndefined();
1357+
expect(result.current[1].status).toEqual('loaded');
1358+
// Exactly two renders: initial 'loading' + transition to 'loaded' after the connection callback fires.
1359+
// If the regression returns, a third render sneaks in from the subscribe-time state reset.
1360+
expect(renderCount).toBe(2);
1361+
});
1362+
1363+
it('should render exactly twice when the key value is only present in storage', async () => {
1364+
await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'storage_value');
1365+
1366+
let renderCount = 0;
1367+
const {result} = renderHook(() => {
1368+
renderCount++;
1369+
return useOnyx(ONYXKEYS.TEST_KEY);
1370+
});
1371+
1372+
await act(async () => waitForPromisesToResolve());
1373+
1374+
expect(result.current[0]).toEqual('storage_value');
1375+
expect(result.current[1].status).toEqual('loaded');
1376+
expect(renderCount).toBe(2);
1377+
});
1378+
1379+
it('should render exactly twice for a non-cached collection member key', async () => {
1380+
let renderCount = 0;
1381+
const {result} = renderHook(() => {
1382+
renderCount++;
1383+
return useOnyx(`${ONYXKEYS.COLLECTION.TEST_KEY}1`);
1384+
});
1385+
1386+
await act(async () => waitForPromisesToResolve());
1387+
1388+
expect(result.current[0]).toBeUndefined();
1389+
expect(result.current[1].status).toEqual('loaded');
1390+
expect(renderCount).toBe(2);
1391+
});
1392+
1393+
// Covers the `if (hasMountedRef.current)` branch — i.e. the reset that runs on key-change re-subscriptions.
1394+
// The reset is what makes the hook transition through 'loading' for the new key instead of leaking the
1395+
// previous key's value/status. These tests verify both the render count AND the loading transition,
1396+
// so removing the reset (regression in the other direction) is also caught.
1397+
it('should transition through loading and render exactly 4 times when switching from a cached key to an uncached one', async () => {
1398+
await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}A`, 'A_value');
1399+
1400+
const renders: Array<{value: unknown; status: string}> = [];
1401+
const {result, rerender} = renderHook(
1402+
(key: string) => {
1403+
const r = useOnyx(key);
1404+
renders.push({value: r[0], status: r[1].status});
1405+
return r;
1406+
},
1407+
{initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A` as string},
1408+
);
1409+
1410+
await act(async () => waitForPromisesToResolve());
1411+
1412+
expect(result.current[0]).toEqual('A_value');
1413+
expect(result.current[1].status).toEqual('loaded');
1414+
const rendersAfterMount = renders.length;
1415+
expect(rendersAfterMount).toBe(1);
1416+
1417+
await act(async () => {
1418+
rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}B`);
1419+
});
1420+
await act(async () => waitForPromisesToResolve());
1421+
1422+
expect(result.current[0]).toBeUndefined();
1423+
expect(result.current[1].status).toEqual('loaded');
1424+
// 1 mount render + 3 renders for the key switch (transient stale render, post-subscribe 'loading',
1425+
// callback-driven 'loaded'). The 'loading' render only happens because the subscribe-time reset
1426+
// clears the previous key's resultRef — removing the reset makes this assertion fail.
1427+
expect(renders.length).toBe(4);
1428+
// Verify the reset took effect: a 'loading' frame must appear after the key change.
1429+
const postSwitchStatuses = renders.slice(rendersAfterMount).map((r) => r.status);
1430+
expect(postSwitchStatuses).toContain('loading');
1431+
expect(postSwitchStatuses[postSwitchStatuses.length - 1]).toBe('loaded');
1432+
});
1433+
1434+
it('should transition through loading and render exactly 3 times when switching between two cached keys', async () => {
1435+
await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}A`, 'A_value');
1436+
await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}B`, 'B_value');
1437+
1438+
const renders: Array<{value: unknown; status: string}> = [];
1439+
const {result, rerender} = renderHook(
1440+
(key: string) => {
1441+
const r = useOnyx(key);
1442+
renders.push({value: r[0], status: r[1].status});
1443+
return r;
1444+
},
1445+
{initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A` as string},
1446+
);
1447+
1448+
await act(async () => waitForPromisesToResolve());
1449+
1450+
expect(result.current[0]).toEqual('A_value');
1451+
expect(renders.length).toBe(1);
1452+
1453+
await act(async () => {
1454+
rerender(`${ONYXKEYS.COLLECTION.TEST_KEY}B`);
1455+
});
1456+
await act(async () => waitForPromisesToResolve());
1457+
1458+
expect(result.current[0]).toEqual('B_value');
1459+
expect(result.current[1].status).toEqual('loaded');
1460+
// 1 mount render + 2 renders for the cached-to-cached switch.
1461+
expect(renders.length).toBe(3);
1462+
});
1463+
});
13241464
});

0 commit comments

Comments
 (0)