@@ -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