Skip to content

Commit 06c0b03

Browse files
authored
Merge pull request #774 from callstack-internal/feature/structural-sharing-cache-pr-4
[Structural Sharing] PR 4: Reference equality in notification and subscription paths
2 parents 27c9456 + ae77844 commit 06c0b03

5 files changed

Lines changed: 248 additions & 107 deletions

File tree

API-INTERNAL.md

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -293,23 +293,6 @@ If the requested key is a collection, it will return an object with all the coll
293293
When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
294294

295295
**Kind**: global function
296-
297-
* [keysChanged()](#keysChanged)
298-
* [~isSubscribedToCollectionKey](#keysChanged..isSubscribedToCollectionKey)
299-
* [~isSubscribedToCollectionMemberKey](#keysChanged..isSubscribedToCollectionMemberKey)
300-
301-
<a name="keysChanged..isSubscribedToCollectionKey"></a>
302-
303-
### keysChanged~isSubscribedToCollectionKey
304-
e.g. Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT, callback: ...});
305-
306-
**Kind**: inner constant of [<code>keysChanged</code>](#keysChanged)
307-
<a name="keysChanged..isSubscribedToCollectionMemberKey"></a>
308-
309-
### keysChanged~isSubscribedToCollectionMemberKey
310-
e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...});
311-
312-
**Kind**: inner constant of [<code>keysChanged</code>](#keysChanged)
313296
<a name="keyChanged"></a>
314297

315298
## keyChanged()

lib/OnyxUtils.ts

Lines changed: 55 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {deepEqual, shallowEqual} from 'fast-equals';
1+
import {shallowEqual} from 'fast-equals';
22
import type {ValueOf} from 'type-fest';
33
import _ from 'underscore';
44
import DevTools from './DevTools';
@@ -510,8 +510,8 @@ function getCachedCollection<TKey extends CollectionKeyBase>(collectionKey: TKey
510510
return filteredCollection;
511511
}
512512

513-
// Return a copy to avoid mutations affecting the cache
514-
return {...collectionData};
513+
// Snapshot is frozen — safe to return by reference
514+
return collectionData;
515515
}
516516

517517
// Fallback to original implementation if collection data not available
@@ -546,81 +546,67 @@ function keysChanged<TKey extends CollectionKeyBase>(
546546
partialCollection: OnyxCollection<KeyValueMapping[TKey]>,
547547
partialPreviousCollection: OnyxCollection<KeyValueMapping[TKey]> | undefined,
548548
): void {
549-
// We prepare the "cached collection" which is the entire collection + the new partial data that
550-
// was merged in via mergeCollection().
551549
const cachedCollection = getCachedCollection(collectionKey);
552-
553550
const previousCollection = partialPreviousCollection ?? {};
554-
555-
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
556-
// individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection
557-
// and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection().
558-
const stateMappingKeys = Object.keys(callbackToStateMapping);
559-
560-
for (const stateMappingKey of stateMappingKeys) {
561-
const subscriber = callbackToStateMapping[stateMappingKey];
562-
if (!subscriber) {
563-
continue;
551+
const changedMemberKeys = Object.keys(partialCollection ?? {});
552+
553+
// Use indexed lookup instead of scanning all subscribers.
554+
// We need subscribers for: (1) the collection key itself, and (2) individual changed member keys.
555+
const collectionSubscriberIDs = onyxKeyToSubscriptionIDs.get(collectionKey) ?? [];
556+
const memberSubscriberIDs: number[] = [];
557+
for (const memberKey of changedMemberKeys) {
558+
const ids = onyxKeyToSubscriptionIDs.get(memberKey);
559+
if (ids) {
560+
for (const id of ids) {
561+
memberSubscriberIDs.push(id);
562+
}
564563
}
564+
}
565565

566-
// Skip iteration if we do not have a collection key or a collection member key on this subscriber
567-
if (!Str.startsWith(subscriber.key, collectionKey)) {
566+
// Notify collection-level subscribers
567+
for (const subID of collectionSubscriberIDs) {
568+
const subscriber = callbackToStateMapping[subID];
569+
if (!subscriber || typeof subscriber.callback !== 'function') {
568570
continue;
569571
}
570572

571-
/**
572-
* e.g. Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT, callback: ...});
573-
*/
574-
const isSubscribedToCollectionKey = subscriber.key === collectionKey;
575-
576-
/**
577-
* e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...});
578-
*/
579-
const isSubscribedToCollectionMemberKey = OnyxKeys.isCollectionMemberKey(collectionKey, subscriber.key);
580-
581-
// Regular Onyx.connect() subscriber found.
582-
if (typeof subscriber.callback === 'function') {
583-
try {
584-
// If they are subscribed to the collection key and using waitForCollectionCallback then we'll
585-
// send the whole cached collection.
586-
if (isSubscribedToCollectionKey) {
587-
lastConnectionCallbackData.set(subscriber.subscriptionID, {value: cachedCollection, matchedKey: subscriber.key});
588-
589-
if (subscriber.waitForCollectionCallback) {
590-
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
591-
continue;
592-
}
573+
try {
574+
lastConnectionCallbackData.set(subscriber.subscriptionID, {value: cachedCollection, matchedKey: subscriber.key});
593575

594-
// If they are not using waitForCollectionCallback then we notify the subscriber with
595-
// the new merged data but only for any keys in the partial collection.
596-
const dataKeys = Object.keys(partialCollection ?? {});
597-
for (const dataKey of dataKeys) {
598-
if (deepEqual(cachedCollection[dataKey], previousCollection[dataKey])) {
599-
continue;
600-
}
576+
if (subscriber.waitForCollectionCallback) {
577+
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
578+
continue;
579+
}
601580

602-
subscriber.callback(cachedCollection[dataKey], dataKey);
603-
}
581+
// Not using waitForCollectionCallback — notify per changed key
582+
for (const dataKey of changedMemberKeys) {
583+
if (cachedCollection[dataKey] === previousCollection[dataKey]) {
604584
continue;
605585
}
586+
subscriber.callback(cachedCollection[dataKey], dataKey);
587+
}
588+
} catch (error) {
589+
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
590+
}
591+
}
606592

607-
// And if the subscriber is specifically only tracking a particular collection member key then we will
608-
// notify them with the cached data for that key only.
609-
if (isSubscribedToCollectionMemberKey) {
610-
if (deepEqual(cachedCollection[subscriber.key], previousCollection[subscriber.key])) {
611-
continue;
612-
}
593+
// Notify member-level subscribers (e.g. subscribed to `report_123`)
594+
for (const subID of memberSubscriberIDs) {
595+
const subscriber = callbackToStateMapping[subID];
596+
if (!subscriber || typeof subscriber.callback !== 'function') {
597+
continue;
598+
}
613599

614-
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
615-
subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey);
616-
lastConnectionCallbackData.set(subscriber.subscriptionID, {value: cachedCollection[subscriber.key], matchedKey: subscriber.key});
617-
continue;
618-
}
600+
if (cachedCollection[subscriber.key] === previousCollection[subscriber.key]) {
601+
continue;
602+
}
619603

620-
continue;
621-
} catch (error) {
622-
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
623-
}
604+
try {
605+
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
606+
subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey);
607+
lastConnectionCallbackData.set(subscriber.subscriptionID, {value: cachedCollection[subscriber.key], matchedKey: subscriber.key});
608+
} catch (error) {
609+
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
624610
}
625611
}
626612
}
@@ -660,6 +646,9 @@ function keyChanged<TKey extends OnyxKey>(
660646
}
661647
}
662648

649+
// Cache the collection snapshot per dispatch so all subscribers to the same collection
650+
// see a consistent view, even if an earlier subscriber's callback synchronously writes
651+
// to the same collection.
663652
const cachedCollections: Record<string, ReturnType<typeof getCachedCollection>> = {};
664653

665654
for (const stateMappingKey of stateMappingKeys) {
@@ -682,14 +671,13 @@ function keyChanged<TKey extends OnyxKey>(
682671
if (isProcessingCollectionUpdate) {
683672
continue;
684673
}
674+
// Cache once per dispatch to ensure all subscribers see a consistent snapshot
675+
// even if a previous callback synchronously wrote to the same collection.
685676
let cachedCollection = cachedCollections[subscriber.key];
686-
687677
if (!cachedCollection) {
688678
cachedCollection = getCachedCollection(subscriber.key);
689679
cachedCollections[subscriber.key] = cachedCollection;
690680
}
691-
692-
cachedCollection[key] = value;
693681
lastConnectionCallbackData.set(subscriber.subscriptionID, {value: cachedCollection, matchedKey: subscriber.key});
694682
subscriber.callback(cachedCollection, subscriber.key, {[key]: value});
695683
continue;

lib/useOnyx.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,17 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
7070
// Recompute if input changed, dependencies changed, or first time
7171
const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies);
7272
if (!hasComputed || lastInput !== input || dependenciesChanged) {
73-
// Only proceed if we have a valid selector
74-
if (selector) {
75-
const newOutput = selector(input);
76-
77-
// Deep equality mode: only update if output actually changed
78-
if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) {
79-
lastInput = input;
80-
lastOutput = newOutput;
81-
lastDependencies = [...currentDependencies];
82-
hasComputed = true;
83-
}
73+
const newOutput = selector(input);
74+
75+
// Always track the current input to avoid re-running the selector
76+
// when the same input is seen again (even if the output didn't change).
77+
lastInput = input;
78+
79+
// Only update the output reference if it actually changed
80+
if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) {
81+
lastOutput = newOutput;
82+
lastDependencies = [...currentDependencies];
83+
hasComputed = true;
8484
}
8585
}
8686

@@ -218,18 +218,11 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
218218
newFetchStatus = 'loading';
219219
}
220220

221-
// Optimized equality checking:
222-
// - Memoized selectors already handle deep equality internally, so we can use fast reference equality
223-
// - Non-selector cases use shallow equality for object reference checks
224-
// - Normalize null to undefined to ensure consistent comparison (both represent "no value")
225-
let areValuesEqual: boolean;
226-
if (memoizedSelector) {
227-
const normalizedPrevious = previousValueRef.current ?? undefined;
228-
const normalizedNew = newValueRef.current ?? undefined;
229-
areValuesEqual = normalizedPrevious === normalizedNew;
230-
} else {
231-
areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
232-
}
221+
// shallowEqual checks === first (O(1) for frozen snapshots and stable selector references),
222+
// then falls back to comparing top-level properties for individual keys that may have
223+
// new references with equivalent content.
224+
// Normalize null to undefined to ensure consistent comparison (both represent "no value").
225+
const areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current ?? undefined);
233226

234227
// We update the cached value and the result in the following conditions:
235228
// We will update the cached value and the result in any of the following situations:

tests/unit/onyxUtilsTest.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,133 @@ describe('OnyxUtils', () => {
349349

350350
await Onyx.disconnect(connection);
351351
});
352+
353+
it('should notify collection-level subscribers with waitForCollectionCallback', async () => {
354+
const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}789`;
355+
const entryData = {value: 'data'};
356+
357+
const collectionCallback = jest.fn();
358+
const connection = Onyx.connect({
359+
key: ONYXKEYS.COLLECTION.TEST_KEY,
360+
callback: collectionCallback,
361+
waitForCollectionCallback: true,
362+
initWithStoredValues: false,
363+
});
364+
365+
await Onyx.set(entryKey, entryData);
366+
collectionCallback.mockClear();
367+
368+
// Trigger keysChanged directly with a partial collection
369+
OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: entryData}, {});
370+
371+
expect(collectionCallback).toHaveBeenCalledTimes(1);
372+
// Collection subscriber receives the full cached collection, subscriber.key, and partial
373+
const [receivedCollection, receivedKey, receivedPartial] = collectionCallback.mock.calls[0];
374+
expect(receivedKey).toBe(ONYXKEYS.COLLECTION.TEST_KEY);
375+
expect(receivedCollection[entryKey]).toEqual(entryData);
376+
expect(receivedPartial).toEqual({[entryKey]: entryData});
377+
378+
Onyx.disconnect(connection);
379+
});
380+
381+
it('should skip notification when member value has same reference in previous and current collection', async () => {
382+
const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}same`;
383+
const sameValue = {value: 'unchanged'};
384+
385+
await Onyx.set(entryKey, sameValue);
386+
387+
const callbackSpy = jest.fn();
388+
const connection = Onyx.connect({
389+
key: entryKey,
390+
callback: callbackSpy,
391+
initWithStoredValues: false,
392+
});
393+
await waitForPromisesToResolve();
394+
callbackSpy.mockClear();
395+
396+
// Simulate keysChanged where the previous and current value are the SAME reference
397+
// (which happens with frozen snapshots when nothing changed). === should skip notification.
398+
OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: sameValue}, {[entryKey]: sameValue});
399+
400+
expect(callbackSpy).not.toHaveBeenCalled();
401+
402+
Onyx.disconnect(connection);
403+
});
404+
405+
it('should notify member subscribers only for changed keys in a batched update', async () => {
406+
const keyA = `${ONYXKEYS.COLLECTION.TEST_KEY}A`;
407+
const keyB = `${ONYXKEYS.COLLECTION.TEST_KEY}B`;
408+
const keyC = `${ONYXKEYS.COLLECTION.TEST_KEY}C`;
409+
410+
const dataA = {value: 'A'};
411+
const dataB = {value: 'B'};
412+
const dataC = {value: 'C'};
413+
414+
await Onyx.multiSet({[keyA]: dataA, [keyB]: dataB, [keyC]: dataC});
415+
416+
const spyA = jest.fn();
417+
const spyB = jest.fn();
418+
const spyC = jest.fn();
419+
const connA = Onyx.connect({key: keyA, callback: spyA, initWithStoredValues: false});
420+
const connB = Onyx.connect({key: keyB, callback: spyB, initWithStoredValues: false});
421+
const connC = Onyx.connect({key: keyC, callback: spyC, initWithStoredValues: false});
422+
await waitForPromisesToResolve();
423+
spyA.mockClear();
424+
spyB.mockClear();
425+
spyC.mockClear();
426+
427+
// Update cache so keysChanged reads the new values via getCachedCollection
428+
const newA = {value: 'A-updated'};
429+
const newC = {value: 'C-updated'};
430+
OnyxCache.set(keyA, newA);
431+
OnyxCache.set(keyC, newC);
432+
// keyB stays the same reference
433+
434+
OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[keyA]: newA, [keyB]: dataB, [keyC]: newC}, {[keyA]: dataA, [keyB]: dataB, [keyC]: dataC});
435+
436+
expect(spyA).toHaveBeenCalledTimes(1);
437+
expect(spyB).not.toHaveBeenCalled();
438+
expect(spyC).toHaveBeenCalledTimes(1);
439+
440+
Onyx.disconnect(connA);
441+
Onyx.disconnect(connB);
442+
Onyx.disconnect(connC);
443+
});
444+
445+
it('should catch errors thrown by subscriber callbacks and continue notifying others', async () => {
446+
const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}errorTest`;
447+
const entryData = {value: 'data'};
448+
449+
await Onyx.set(entryKey, entryData);
450+
451+
const failingCallback = jest.fn(() => {
452+
throw new Error('subscriber failure');
453+
});
454+
const workingCallback = jest.fn();
455+
456+
const connFailing = Onyx.connect({key: entryKey, callback: failingCallback, initWithStoredValues: false});
457+
const connWorking = Onyx.connect({key: entryKey, callback: workingCallback, initWithStoredValues: false});
458+
await waitForPromisesToResolve();
459+
failingCallback.mockClear();
460+
workingCallback.mockClear();
461+
462+
// Spy on Logger to verify the error is logged
463+
const logSpy = jest.spyOn(Logger, 'logAlert').mockImplementation(() => undefined);
464+
465+
const newData = {value: 'new'};
466+
// Update the cache so keysChanged sees the new value as different from previous
467+
OnyxCache.set(entryKey, newData);
468+
OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: newData}, {[entryKey]: entryData});
469+
470+
// Both callbacks should have been attempted; error should be logged
471+
expect(failingCallback).toHaveBeenCalled();
472+
expect(workingCallback).toHaveBeenCalled();
473+
expect(logSpy).toHaveBeenCalled();
474+
475+
logSpy.mockRestore();
476+
Onyx.disconnect(connFailing);
477+
Onyx.disconnect(connWorking);
478+
});
352479
});
353480

354481
describe('mergeChanges', () => {

0 commit comments

Comments
 (0)