diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c777d30ac..f85862309 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -622,6 +622,7 @@ function keysChanged( partialPreviousCollection: OnyxCollection | undefined, notifyConnectSubscribers = true, notifyWithOnyxSubscribers = true, + notifyUseOnyxHookSubscribers = true, ): void { // We prepare the "cached collection" which is the entire collection + the new partial data that // was merged in via mergeCollection(). @@ -657,7 +658,8 @@ function keysChanged( // Regular Onyx.connect() subscriber found. if (typeof subscriber.callback === 'function') { - if (!notifyConnectSubscribers) { + // Check if it's a useOnyx or a regular Onyx.connect() subscriber + if ((subscriber.isUseOnyxSubscriber && !notifyUseOnyxHookSubscribers) || (!subscriber.isUseOnyxSubscriber && !notifyConnectSubscribers)) { continue; } @@ -816,6 +818,7 @@ function keyChanged( canUpdateSubscriber: (subscriber?: Mapping) => boolean = () => true, notifyConnectSubscribers = true, notifyWithOnyxSubscribers = true, + notifyUseOnyxHookSubscribers = true, ): void { // Add or remove this key from the recentlyAccessedKeys lists if (value !== null) { @@ -857,7 +860,8 @@ function keyChanged( // Subscriber is a regular call to connect() and provided a callback if (typeof subscriber.callback === 'function') { - if (!notifyConnectSubscribers) { + // Check if it's a useOnyx or a regular Onyx.connect() subscriber + if ((subscriber.isUseOnyxSubscriber && !notifyUseOnyxHookSubscribers) || (!subscriber.isUseOnyxSubscriber && !notifyConnectSubscribers)) { continue; } if (lastConnectionCallbackData.has(subscriber.subscriptionID) && lastConnectionCallbackData.get(subscriber.subscriptionID) === value) { @@ -1078,8 +1082,8 @@ function scheduleSubscriberUpdate( previousValue: OnyxValue, canUpdateSubscriber: (subscriber?: Mapping) => boolean = () => true, ): Promise { - const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false)); - batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true)); + const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false, false)); + batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true, true)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } @@ -1093,8 +1097,8 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true, false)); - batchUpdates(() => keysChanged(key, value, previousValue, false, true)); + const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true, false, false)); + batchUpdates(() => keysChanged(key, value, previousValue, false, true, true)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } diff --git a/lib/types.ts b/lib/types.ts index 07b6ddd3c..e9b9ae6c5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -273,6 +273,9 @@ type BaseConnectOptions = { * with the same connect configurations. */ reuseConnection?: boolean; + + /** Indicates whether this subscriber is created from the useOnyx hook. */ + isUseOnyxSubscriber?: boolean; }; /** Represents the callback function used in `Onyx.connect()` method with a regular key. */ diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index b2c060e5d..0ce761383 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -322,6 +322,7 @@ function useOnyx>( initWithStoredValues: options?.initWithStoredValues, waitForCollectionCallback: OnyxUtils.isCollectionKey(key) as true, reuseConnection: options?.reuseConnection, + isUseOnyxSubscriber: true, }); checkEvictableKey(); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 27f569b2d..fae7406d7 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -157,7 +157,7 @@ describe('useOnyx', () => { }); it('should return value and loaded state when loading cached key', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, 'test'); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test')); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); @@ -196,7 +196,7 @@ describe('useOnyx', () => { }); it('should return value from cache, and return updated value after a merge operation', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, 'test1'); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test1')); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); @@ -222,8 +222,7 @@ describe('useOnyx', () => { expect(result2.current[0]).toBeUndefined(); expect(result2.current[1].status).toEqual('loaded'); - Onyx.merge(ONYXKEYS.TEST_KEY, 'test2'); - await act(async () => waitForPromisesToResolve()); + await act(async () => Onyx.merge(ONYXKEYS.TEST_KEY, 'test2')); expect(result1.current[0]).toEqual('test2'); expect(result1.current[1].status).toEqual('loaded'); @@ -255,8 +254,7 @@ describe('useOnyx', () => { expect(result3.current[0]).toBeUndefined(); expect(result3.current[1].status).toEqual('loaded'); - Onyx.merge(ONYXKEYS.TEST_KEY, 'test2'); - await act(async () => waitForPromisesToResolve()); + await act(async () => Onyx.merge(ONYXKEYS.TEST_KEY, 'test2')); expect(result1.current[0]).toEqual('test2'); expect(result1.current[1].status).toEqual('loaded'); @@ -269,7 +267,7 @@ describe('useOnyx', () => { describe('selector', () => { it('should return selected data from a non-collection key', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'})); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -288,11 +286,13 @@ describe('useOnyx', () => { }); it('should return selected data from a collection key', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + } as GenericCollection), + ); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { @@ -315,6 +315,9 @@ describe('useOnyx', () => { expect(result.current[1].status).toEqual('loaded'); await act(async () => Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, null)); + // Onyx.merge() with null value resolves immediately, but notifies subscribers asynchronously. + // waitForPromisesToResolve() ensures these async operations complete before test assertions run. + await act(async () => waitForPromisesToResolve()); expect(result.current[0]).toEqual({ [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: 'entry1_id', @@ -324,7 +327,7 @@ describe('useOnyx', () => { }); it('should not change selected data if a property outside that data was changed', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'})); // primitive const {result: primitiveResult} = renderHook(() => @@ -365,11 +368,13 @@ describe('useOnyx', () => { }); it('should not change selected collection data if a property outside that data was changed', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + } as GenericCollection), + ); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { @@ -389,7 +394,7 @@ describe('useOnyx', () => { }); it('should always use the current selector reference to return new data', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'})); let selector = (entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`; @@ -412,7 +417,7 @@ describe('useOnyx', () => { }); it('should memoize selector output and return same reference when input unchanged', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name', count: 1}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name', count: 1})); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -436,7 +441,7 @@ describe('useOnyx', () => { }); it('should return new reference when selector input changes', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'})); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -463,7 +468,7 @@ describe('useOnyx', () => { it('should memoize selector output using deep equality check', async () => { let selectorCallCount = 0; - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'})); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -490,7 +495,7 @@ describe('useOnyx', () => { }); it('should memoize primitive selector results correctly', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {count: 5, name: 'test'}); + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, {count: 5, name: 'test'})); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -834,11 +839,13 @@ describe('useOnyx', () => { describe('dependencies', () => { it('should return the updated selected value when a external value passed to the dependencies list changes', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + } as GenericCollection), + ); let externalValue = 'ex1'; @@ -883,11 +890,13 @@ describe('useOnyx', () => { describe('skippable collection member ids', () => { it('should always return undefined entry when subscribing to a collection with skippable member ids', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, + } as GenericCollection), + ); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); @@ -962,6 +971,9 @@ describe('useOnyx', () => { expect(result1.current[0]).toBe('test'); await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null)); + // Onyx.set() with null value resolves immediately, but notifies subscribers asynchronously. + // waitForPromisesToResolve() ensures these async operations complete before test assertions run. + await act(async () => waitForPromisesToResolve()); expect(result1.current[0]).toBeUndefined(); expect(logAlertFn).not.toBeCalled(); @@ -995,6 +1007,9 @@ describe('useOnyx', () => { expect(result1.current[0]).toBe('test'); await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null)); + // Onyx.set() with null value resolves immediately, but notifies subscribers asynchronously. + // waitForPromisesToResolve() ensures these async operations complete before test assertions run. + await act(async () => waitForPromisesToResolve()); expect(result1.current[0]).toBeUndefined(); expect(logAlertFn).toHaveBeenCalledTimes(2); @@ -1026,6 +1041,9 @@ describe('useOnyx', () => { expect(result1.current[0]).toBe('test_changed'); await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null)); + // Onyx.set() with null value resolves immediately, but notifies subscribers asynchronously. + // waitForPromisesToResolve() ensures these async operations complete before test assertions run. + await act(async () => waitForPromisesToResolve()); expect(result1.current[0]).toBeUndefined(); expect(logAlertFn).toHaveBeenCalledTimes(2); @@ -1054,6 +1072,9 @@ describe('useOnyx', () => { expect(result1.current[0]).toBe('test_changed'); await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null)); + // Onyx.set() with null value resolves immediately, but notifies subscribers asynchronously. + // waitForPromisesToResolve() ensures these async operations complete before test assertions run. + await act(async () => waitForPromisesToResolve()); expect(result1.current[0]).toBe('undefined_changed'); expect(logAlertFn).toHaveBeenCalledTimes(2); @@ -1113,9 +1134,11 @@ describe('useOnyx', () => { }); it('should add the connection to the blocklist when setting "canEvict" to false', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { - [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { + [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + } as GenericCollection), + ); renderHook(() => useOnyx(`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`, {canEvict: false})); @@ -1126,10 +1149,12 @@ describe('useOnyx', () => { }); it('should handle removal/adding the connection to the blocklist properly when changing the evictable key to another', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { - [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { + [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + } as GenericCollection), + ); const {rerender} = renderHook((key: string) => useOnyx(key, {canEvict: false}), {initialProps: `${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1` as string}); @@ -1148,9 +1173,11 @@ describe('useOnyx', () => { }); it('should remove the connection from the blocklist when setting "canEvict" to true', async () => { - Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { - [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - } as GenericCollection); + await act(async () => + Onyx.mergeCollection(ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY, { + [`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + } as GenericCollection), + ); const {rerender} = renderHook((canEvict: boolean) => useOnyx(`${ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY}entry1`, {canEvict}), {initialProps: false as boolean});