Skip to content

Commit b791b07

Browse files
committed
Merge branch 'main' into feature/structural-sharing-cache-pr-5-standalone
2 parents 56242e0 + 2727dad commit b791b07

7 files changed

Lines changed: 251 additions & 110 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:

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.69",
3+
"version": "3.0.70",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

0 commit comments

Comments
 (0)