Skip to content

Commit a607b36

Browse files
authored
Merge pull request #752 from callstack-internal/sync-cache-2
Load all storage data into cache during Onyx.init
2 parents 781c848 + f2b0a64 commit a607b36

11 files changed

Lines changed: 211 additions & 20 deletions

File tree

lib/Onyx.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,13 @@ function init({
8282

8383
OnyxUtils.initStoreValues(keys, initialKeyStates, evictableKeys);
8484

85-
// Initialize all of our keys with data provided then give green light to any pending connections
86-
Promise.all([cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys), OnyxUtils.initializeWithDefaultKeyStates()]).then(
87-
OnyxUtils.getDeferredInitTask().resolve,
88-
);
85+
// Initialize all of our keys with data provided then give green light to any pending connections.
86+
// addEvictableKeysToRecentlyAccessedList must run after initializeWithDefaultKeyStates because
87+
// eager cache loading populates the key index (cache.setAllKeys) inside initializeWithDefaultKeyStates,
88+
// and the evictable keys list depends on that index being populated.
89+
OnyxUtils.initializeWithDefaultKeyStates()
90+
.then(() => cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys))
91+
.then(OnyxUtils.getDeferredInitTask().resolve);
8992
}
9093

9194
/**

lib/OnyxUtils.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ let lastSubscriptionID = 0;
100100
// Connections can be made before `Onyx.init`. They would wait for this task before resolving
101101
const deferredInitTask = createDeferredTask();
102102

103-
// Holds a set of collection member IDs which updates will be ignored when using Onyx methods.
103+
// Collection member IDs that Onyx should silently ignore across all operations — reads, writes, cache, and subscriber
104+
// notifications. This is used to filter out keys formed from invalid/default IDs (e.g. "-1", "0",
105+
// "undefined", "null", "NaN") that can appear when an ID variable is accidentally coerced to string.
104106
let skippableCollectionMemberIDs = new Set<string>();
105107
// Holds a set of keys that should always be merged into snapshot entries.
106108
let snapshotMergeKeys = new Set<string>();
@@ -1111,19 +1113,76 @@ function mergeInternal<TValue extends OnyxInput<OnyxKey> | undefined, TChange ex
11111113
* Merge user provided default key value pairs.
11121114
*/
11131115
function initializeWithDefaultKeyStates(): Promise<void> {
1114-
// Filter out RAM-only keys from storage reads as they may have stale persisted data
1115-
// from before the key was migrated to RAM-only.
1116-
const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !isRamOnlyKey(key));
1117-
return Storage.multiGet(keysToFetch).then((pairs) => {
1118-
const existingDataAsObject = Object.fromEntries(pairs) as Record<string, unknown>;
1119-
1120-
const merged = utils.fastMerge(existingDataAsObject, defaultKeyStates, {
1121-
shouldRemoveNestedNulls: true,
1122-
}).result;
1123-
cache.merge(merged ?? {});
1124-
1125-
for (const [key, value] of Object.entries(merged ?? {})) keyChanged(key, value);
1126-
});
1116+
// Eagerly load the entire database into cache in a single batch read.
1117+
// This is faster than lazy-loading individual keys because:
1118+
// 1. One DB transaction instead of hundreds
1119+
// 2. All subsequent reads are synchronous cache hits
1120+
return Storage.getAll()
1121+
.then((pairs) => {
1122+
const allDataFromStorage: Record<string, unknown> = {};
1123+
for (const [key, value] of pairs) {
1124+
// RAM-only keys should not be cached from storage as they may have stale persisted data
1125+
// from before the key was migrated to RAM-only.
1126+
if (isRamOnlyKey(key)) {
1127+
continue;
1128+
}
1129+
1130+
// Skip collection members that are marked as skippable
1131+
if (skippableCollectionMemberIDs.size && getCollectionKey(key)) {
1132+
const [, collectionMemberID] = splitCollectionMemberKey(key);
1133+
1134+
if (skippableCollectionMemberIDs.has(collectionMemberID)) {
1135+
continue;
1136+
}
1137+
}
1138+
1139+
allDataFromStorage[key] = value;
1140+
}
1141+
1142+
// Load all storage data into cache silently (no subscriber notifications)
1143+
cache.setAllKeys(Object.keys(allDataFromStorage));
1144+
cache.merge(allDataFromStorage);
1145+
1146+
// For keys that have a developer-defined default (via `initialKeyStates`), merge the
1147+
// persisted value with the default so new properties added in code updates are applied
1148+
// without wiping user data that already exists in storage.
1149+
const defaultKeysFromStorage = Object.keys(defaultKeyStates).reduce((obj: Record<string, unknown>, key) => {
1150+
if (key in allDataFromStorage) {
1151+
// eslint-disable-next-line no-param-reassign
1152+
obj[key] = allDataFromStorage[key];
1153+
}
1154+
return obj;
1155+
}, {});
1156+
1157+
const merged = utils.fastMerge(defaultKeysFromStorage, defaultKeyStates, {
1158+
shouldRemoveNestedNulls: true,
1159+
}).result;
1160+
cache.merge(merged ?? {});
1161+
1162+
// Notify subscribers about default key states so that any subscriber that connected
1163+
// before init (e.g. during module load) receives the merged default values immediately
1164+
for (const [key, value] of Object.entries(merged ?? {})) {
1165+
keyChanged(key, value);
1166+
}
1167+
})
1168+
.catch((error) => {
1169+
Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`);
1170+
1171+
// Populate the key index so getAllKeys() returns correct results for default keys.
1172+
// Without this, subscribers that check getAllKeys() would see an empty set even
1173+
// though we have default values in cache.
1174+
cache.setAllKeys(Object.keys(defaultKeyStates));
1175+
1176+
// Boot with defaults so the app renders instead of deadlocking.
1177+
// Users will get a fresh-install experience but the app won't be bricked.
1178+
cache.merge(defaultKeyStates);
1179+
1180+
// Notify subscribers about default key states so that any subscriber that connected
1181+
// before init (e.g. during module load) receives the merged default values immediately
1182+
for (const [key, value] of Object.entries(defaultKeyStates)) {
1183+
keyChanged(key, value);
1184+
}
1185+
});
11271186
}
11281187

11291188
/**

lib/storage/__mocks__/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const StorageMock = {
1616
removeItems: jest.fn(MemoryOnlyProvider.removeItems),
1717
clear: jest.fn(MemoryOnlyProvider.clear),
1818
getAllKeys: jest.fn(MemoryOnlyProvider.getAllKeys),
19+
getAll: jest.fn(MemoryOnlyProvider.getAll),
1920
getDatabaseSize: jest.fn(MemoryOnlyProvider.getDatabaseSize),
2021
keepInstancesSync: jest.fn(),
2122

lib/storage/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ const storage: Storage = {
187187
*/
188188
getAllKeys: () => tryOrDegradePerformance(() => provider.getAllKeys()),
189189

190+
/**
191+
* Returns all key-value pairs from storage in a single batch operation
192+
*/
193+
getAll: () => tryOrDegradePerformance(() => provider.getAll()),
194+
190195
/**
191196
* Gets the total bytes of the store
192197
*/
@@ -220,6 +225,7 @@ GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => {
220225
storage.removeItems = decorateWithMetrics(storage.removeItems, 'Storage.removeItems');
221226
storage.clear = decorateWithMetrics(storage.clear, 'Storage.clear');
222227
storage.getAllKeys = decorateWithMetrics(storage.getAllKeys, 'Storage.getAllKeys');
228+
storage.getAll = decorateWithMetrics(storage.getAll, 'Storage.getAll');
223229
});
224230

225231
export default storage;

lib/storage/providers/IDBKeyValProvider/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import utils from '../../../utils';
44
import type StorageProvider from '../types';
55
import type {OnyxKey, OnyxValue} from '../../../types';
66
import createStore from './createStore';
7+
import type {StorageKeyValuePair} from '../types';
78

89
const DB_NAME = 'OnyxDB';
910
const STORE_NAME = 'keyvaluepairs';
@@ -109,6 +110,13 @@ const provider: StorageProvider<UseStore | undefined> = {
109110

110111
return IDB.keys(provider.store);
111112
},
113+
getAll() {
114+
if (!provider.store) {
115+
throw new Error('Store not initialized!');
116+
}
117+
118+
return IDB.entries(provider.store) as Promise<StorageKeyValuePair[]>;
119+
},
112120
getItem(key) {
113121
if (!provider.store) {
114122
throw new Error('Store not initialized!');

lib/storage/providers/MemoryOnlyProvider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ const provider: StorageProvider<Store> = {
135135
return Promise.resolve(_.keys(provider.store));
136136
},
137137

138+
/**
139+
* Returns all key-value pairs from memory
140+
*/
141+
getAll() {
142+
return Promise.resolve(Object.entries(provider.store) as StorageKeyValuePair[]);
143+
},
144+
138145
/**
139146
* Gets the total bytes of the store.
140147
* `bytesRemaining` will always be `Number.POSITIVE_INFINITY` since we don't have a hard limit on memory.

lib/storage/providers/NoopProvider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ const provider: StorageProvider<unknown> = {
8787
return Promise.resolve([]);
8888
},
8989

90+
/**
91+
* Returns all key-value pairs from storage
92+
*/
93+
getAll() {
94+
return Promise.resolve([]);
95+
},
96+
9097
/**
9198
* Gets the total bytes of the store.
9299
* `bytesRemaining` will always be `Number.POSITIVE_INFINITY` since we don't have a hard limit on memory.

lib/storage/providers/SQLiteProvider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ const provider: StorageProvider<NitroSQLiteConnection | undefined> = {
198198
return (result ?? []) as StorageKeyList;
199199
});
200200
},
201+
getAll() {
202+
if (!provider.store) {
203+
throw new Error('Store is not initialized!');
204+
}
205+
206+
return provider.store.executeAsync<OnyxSQLiteKeyValuePair>('SELECT record_key, valueJSON FROM keyvaluepairs;').then(({rows}) => {
207+
// eslint-disable-next-line no-underscore-dangle
208+
const result = rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]);
209+
return (result ?? []) as StorageKeyValuePair[];
210+
});
211+
},
201212
removeItem(key) {
202213
if (!provider.store) {
203214
throw new Error('Store is not initialized!');

lib/storage/providers/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ type StorageProvider<TStore> = {
6060
*/
6161
getAllKeys: () => Promise<StorageKeyList>;
6262

63+
/**
64+
* Returns all key-value pairs from storage in a single batch operation.
65+
* More efficient than getAllKeys + multiGet for loading the entire database.
66+
*/
67+
getAll: () => Promise<StorageKeyValuePair[]>;
68+
6369
/**
6470
* Removes given key and its value from storage
6571
*/

lib/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,9 @@ type InitOptions = {
421421
enableDevTools?: boolean;
422422

423423
/**
424-
* Array of collection member IDs which updates will be ignored when using Onyx methods.
425-
* Additionally, any subscribers from these keys to won't receive any data from Onyx.
424+
* Array of collection member IDs that Onyx should silently ignore across all operations.
425+
* This prevents keys formed from invalid or default IDs (e.g. "-1", "0", "undefined") from
426+
* polluting cache or triggering subscriber notifications.
426427
*/
427428
skippableCollectionMemberIDs?: string[];
428429

0 commit comments

Comments
 (0)