Skip to content

Commit 9fa8b70

Browse files
Computed values
1 parent 9f6c405 commit 9fa8b70

File tree

8 files changed

+398
-21
lines changed

8 files changed

+398
-21
lines changed

lib/Onyx.ts

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import Storage from './storage';
88
import utils from './utils';
99
import DevTools from './DevTools';
1010
import type {
11+
AnyComputedKey,
1112
Collection,
1213
CollectionKeyBase,
14+
ComputedKey,
1315
ConnectOptions,
1416
InitOptions,
1517
KeyValueMapping,
@@ -63,6 +65,15 @@ function init({
6365
Promise.all([OnyxUtils.addAllSafeEvictionKeysToRecentlyAccessedList(), OnyxUtils.initializeWithDefaultKeyStates()]).then(deferredInitTask.resolve);
6466
}
6567

68+
function computeAndSendData(mapping: Mapping<AnyComputedKey>, dependencies: Record<string, unknown>) {
69+
let val = cache.getValue(mapping.key.cacheKey);
70+
if (val === undefined) {
71+
val = mapping.key.compute(dependencies);
72+
cache.set(mapping.key.cacheKey, val);
73+
}
74+
OnyxUtils.sendDataToConnection(mapping, val, mapping.key.cacheKey, true);
75+
}
76+
6677
/**
6778
* Subscribes a react component's state directly to a store key
6879
*
@@ -91,12 +102,54 @@ function init({
91102
* Note that it will not cause the component to have the loading prop set to true.
92103
* @returns an ID to use when calling disconnect
93104
*/
94-
function connect<TKey extends OnyxKey>(mapping: ConnectOptions<TKey>): number {
105+
function connect<TKey extends OnyxKey | AnyComputedKey>(mapping: ConnectOptions<TKey>): number {
95106
const connectionID = lastConnectionID++;
96107
const callbackToStateMapping = OnyxUtils.getCallbackToStateMapping();
97108
callbackToStateMapping[connectionID] = mapping;
98109
callbackToStateMapping[connectionID].connectionID = connectionID;
99110

111+
const mappingKey = mapping.key;
112+
if (OnyxUtils.isComputedKey(mappingKey)) {
113+
deferredInitTask.promise
114+
.then(() => OnyxUtils.addKeyToRecentlyAccessedIfNeeded(mapping))
115+
.then(() => {
116+
const mappingDependencies = mappingKey.dependencies || {};
117+
const dependenciesCount = _.size(mappingDependencies);
118+
if (dependenciesCount === 0) {
119+
// If we have no dependencies we can send the computed value immediately.
120+
computeAndSendData(mapping as Mapping<AnyComputedKey>, {});
121+
} else {
122+
callbackToStateMapping[connectionID].dependencyConnections = [];
123+
124+
const dependencyValues: Record<string, unknown> = {};
125+
_.each(mappingDependencies, (dependency, dependencyKey) => {
126+
// Create a mapping of dependent cache keys so when a key changes, all dependent keys
127+
// can also be cleared from the cache.
128+
const cacheKey = OnyxUtils.getCacheKey(dependency);
129+
OnyxUtils.addDependentCacheKey(cacheKey, mappingKey.cacheKey);
130+
131+
// Connect to dependencies.
132+
const dependencyConnection = connect({
133+
key: dependency,
134+
waitForCollectionCallback: true,
135+
callback: (value) => {
136+
dependencyValues[dependencyKey] = value;
137+
138+
// Once all dependencies are ready, compute the value and send it to the connection.
139+
if (_.size(dependencyValues) === dependenciesCount) {
140+
computeAndSendData(mapping as Mapping<AnyComputedKey>, dependencyValues);
141+
}
142+
},
143+
});
144+
145+
// Store dependency connections so we can disconnect them later.
146+
callbackToStateMapping[connectionID].dependencyConnections.push(dependencyConnection);
147+
});
148+
}
149+
});
150+
return connectionID;
151+
}
152+
100153
if (mapping.initWithStoredValues === false) {
101154
return connectionID;
102155
}
@@ -108,24 +161,24 @@ function connect<TKey extends OnyxKey>(mapping: ConnectOptions<TKey>): number {
108161
// Performance improvement
109162
// If the mapping is connected to an onyx key that is not a collection
110163
// we can skip the call to getAllKeys() and return an array with a single item
111-
if (Boolean(mapping.key) && typeof mapping.key === 'string' && !mapping.key.endsWith('_') && cache.storageKeys.has(mapping.key)) {
112-
return new Set([mapping.key]);
164+
if (Boolean(mappingKey) && typeof mappingKey === 'string' && !mappingKey.endsWith('_') && cache.storageKeys.has(mappingKey)) {
165+
return new Set([mappingKey]);
113166
}
114167
return OnyxUtils.getAllKeys();
115168
})
116169
.then((keys) => {
117170
// We search all the keys in storage to see if any are a "match" for the subscriber we are connecting so that we
118171
// can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be
119172
// subscribed to a "collection key" or a single key.
120-
const matchingKeys = Array.from(keys).filter((key) => OnyxUtils.isKeyMatch(mapping.key, key));
173+
const matchingKeys = Array.from(keys).filter((key) => OnyxUtils.isKeyMatch(mappingKey, key));
121174

122175
// If the key being connected to does not exist we initialize the value with null. For subscribers that connected
123176
// directly via connect() they will simply get a null value sent to them without any information about which key matched
124177
// since there are none matched. In withOnyx() we wait for all connected keys to return a value before rendering the child
125178
// component. This null value will be filtered out so that the connected component can utilize defaultProps.
126179
if (matchingKeys.length === 0) {
127-
if (mapping.key && !OnyxUtils.isCollectionKey(mapping.key)) {
128-
cache.set(mapping.key, null);
180+
if (mappingKey && !OnyxUtils.isCollectionKey(mappingKey)) {
181+
cache.set(mappingKey, null);
129182
}
130183

131184
// Here we cannot use batching because the null value is expected to be set immediately for default props
@@ -138,7 +191,7 @@ function connect<TKey extends OnyxKey>(mapping: ConnectOptions<TKey>): number {
138191
// into an object and just make a single call. The latter behavior is enabled by providing a waitForCollectionCallback key
139192
// combined with a subscription to a collection key.
140193
if (typeof mapping.callback === 'function') {
141-
if (OnyxUtils.isCollectionKey(mapping.key)) {
194+
if (OnyxUtils.isCollectionKey(mappingKey)) {
142195
if (mapping.waitForCollectionCallback) {
143196
OnyxUtils.getCollectionDataAndSendAsObject(matchingKeys, mapping);
144197
return;
@@ -147,26 +200,26 @@ function connect<TKey extends OnyxKey>(mapping: ConnectOptions<TKey>): number {
147200
// We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key.
148201
// eslint-disable-next-line @typescript-eslint/prefer-for-of
149202
for (let i = 0; i < matchingKeys.length; i++) {
150-
OnyxUtils.get(matchingKeys[i]).then((val) => OnyxUtils.sendDataToConnection(mapping, val, matchingKeys[i] as TKey, true));
203+
OnyxUtils.get(matchingKeys[i]).then((val) => OnyxUtils.sendDataToConnection(mapping, val, matchingKeys[i], true));
151204
}
152205
return;
153206
}
154207

155208
// If we are not subscribed to a collection key then there's only a single key to send an update for.
156-
OnyxUtils.get(mapping.key).then((val) => OnyxUtils.sendDataToConnection(mapping, val, mapping.key, true));
209+
OnyxUtils.get(mappingKey).then((val) => OnyxUtils.sendDataToConnection(mapping, val, mappingKey, true));
157210
return;
158211
}
159212

160213
// If we have a withOnyxInstance that means a React component has subscribed via the withOnyx() HOC and we need to
161214
// group collection key member data into an object.
162215
if (mapping.withOnyxInstance) {
163-
if (OnyxUtils.isCollectionKey(mapping.key)) {
216+
if (OnyxUtils.isCollectionKey(mappingKey)) {
164217
OnyxUtils.getCollectionDataAndSendAsObject(matchingKeys, mapping);
165218
return;
166219
}
167220

168221
// If the subscriber is not using a collection key then we just send a single value back to the subscriber
169-
OnyxUtils.get(mapping.key).then((val) => OnyxUtils.sendDataToConnection(mapping, val, mapping.key, true));
222+
OnyxUtils.get(mappingKey).then((val) => OnyxUtils.sendDataToConnection(mapping, val, mappingKey, true));
170223
return;
171224
}
172225

@@ -197,6 +250,10 @@ function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: Ony
197250
OnyxUtils.removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID);
198251
}
199252

253+
if (callbackToStateMapping[connectionID].dependencyConnections) {
254+
callbackToStateMapping[connectionID].dependencyConnections.forEach((id: number) => disconnect(id));
255+
}
256+
200257
delete callbackToStateMapping[connectionID];
201258
}
202259

lib/OnyxUtils.d.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Component} from 'react';
22
import * as Logger from './Logger';
3-
import {CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types';
3+
import {AnyComputedKey, ComputedKey, CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types';
44

55
declare const METHOD: {
66
readonly SET: 'set';
@@ -275,6 +275,21 @@ declare function applyMerge(existingValue: OnyxValue<OnyxKey>, changes: Array<On
275275
*/
276276
declare function initializeWithDefaultKeyStates(): Promise<void>;
277277

278+
/**
279+
* Returns a string cache key for a possible computed key.
280+
*/
281+
declare function getCacheKey(key: OnyxKey | AnyComputedKey): string;
282+
283+
/**
284+
* Returns if a key is a computed key.
285+
*/
286+
declare function isComputedKey(key: OnyxKey | AnyComputedKey): key is AnyComputedKey;
287+
288+
/**
289+
* Adds an entry in the dependent cache key map.
290+
*/
291+
declare function addDependentCacheKey(key: OnyxKey, dependentKey: OnyxKey): void;
292+
278293
const OnyxUtils = {
279294
METHOD,
280295
getMergeQueue,
@@ -315,6 +330,9 @@ const OnyxUtils = {
315330
prepareKeyValuePairsForStorage,
316331
applyMerge,
317332
initializeWithDefaultKeyStates,
333+
getCacheKey,
334+
isComputedKey,
335+
addDependentCacheKey,
318336
} as const;
319337

320338
export default OnyxUtils;

lib/OnyxUtils.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const mergeQueuePromise = {};
2626
// Holds a mapping of all the react components that want their state subscribed to a store key
2727
const callbackToStateMapping = {};
2828

29+
// Holds a mapping of cache keys to their dependencies. This is used to invalidate computed keys.
30+
const dependentCacheKeys = {};
31+
2932
// Keeps a copy of the values of the onyx collection keys as a map for faster lookups
3033
let onyxCollectionKeyMap = new Map();
3134

@@ -191,6 +194,16 @@ const reduceCollectionWithSelector = (collection, selector, withOnyxInstanceStat
191194
{},
192195
);
193196

197+
/**
198+
* Returns if the key is a computed key.
199+
*
200+
* @param {Mixed} key
201+
* @returns {boolean}
202+
*/
203+
function isComputedKey(key) {
204+
return typeof key === 'object' && 'compute' in key;
205+
}
206+
194207
/**
195208
* Get some data from the store
196209
*
@@ -311,6 +324,16 @@ function isSafeEvictionKey(testKey) {
311324
return _.some(evictionAllowList, (key) => isKeyMatch(key, testKey));
312325
}
313326

327+
/**
328+
* Returns a string cache key for a possible computed key.
329+
*
330+
* @param {Mixed} key
331+
* @returns {String}
332+
*/
333+
function getCacheKey(key) {
334+
return isComputedKey(key) ? key.cacheKey : key;
335+
}
336+
314337
/**
315338
* Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined.
316339
* If the requested key is a collection, it will return an object with all the collection members.
@@ -320,6 +343,30 @@ function isSafeEvictionKey(testKey) {
320343
* @returns {Mixed}
321344
*/
322345
function tryGetCachedValue(key, mapping = {}) {
346+
if (isComputedKey(key)) {
347+
// Check if we have the value in cache already.
348+
let val = cache.getValue(key.cacheKey);
349+
if (val !== undefined) {
350+
return val;
351+
}
352+
353+
// Check if we can compute the value if all dependencies are in cache.
354+
const dependencies = _.mapObject(key.dependencies || {}, (dependencyKey) =>
355+
tryGetCachedValue(
356+
dependencyKey,
357+
// TODO: We could support full mapping here.
358+
{key: dependencyKey},
359+
),
360+
);
361+
if (_.all(dependencies, (dependency) => dependency !== undefined)) {
362+
val = key.compute(dependencies);
363+
cache.set(key.cacheKey, val);
364+
return val;
365+
}
366+
367+
return undefined;
368+
}
369+
323370
let val = cache.getValue(key);
324371

325372
if (isCollectionKey(key)) {
@@ -480,6 +527,24 @@ function getCachedCollection(collectionKey) {
480527
);
481528
}
482529

530+
function clearComputedCacheForKey(key) {
531+
const dependentKeys = dependentCacheKeys[key];
532+
if (!dependentKeys) {
533+
return;
534+
}
535+
536+
dependentKeys.forEach((dependentKey) => {
537+
cache.drop(dependentKey);
538+
539+
clearComputedCacheForKey(dependentKey);
540+
});
541+
}
542+
543+
function addDependentCacheKey(key, dependentKey) {
544+
dependentCacheKeys[key] = dependentCacheKeys[key] || new Set();
545+
dependentCacheKeys[key].add(dependentKey);
546+
}
547+
483548
/**
484549
* When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
485550
*
@@ -490,6 +555,8 @@ function getCachedCollection(collectionKey) {
490555
* @param {boolean} [notifyWithOnyxSubscibers=true]
491556
*/
492557
function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) {
558+
clearComputedCacheForKey(collectionKey);
559+
493560
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
494561
// 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
495562
// 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().
@@ -667,6 +734,8 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif
667734
removeLastAccessedKey(key);
668735
}
669736

737+
clearComputedCacheForKey(key);
738+
670739
// We are iterating over all subscribers to see if they are interested in the key that has just changed. If the subscriber's key is a collection key then we will
671740
// notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. Depending on whether the subscriber
672741
// was connected via withOnyx we will call setState() directly on the withOnyx instance. If it is a regular connection we will pass the data to the provided callback.
@@ -851,7 +920,7 @@ function addKeyToRecentlyAccessedIfNeeded(mapping) {
851920
throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`);
852921
}
853922

854-
addLastAccessedKey(mapping.key);
923+
addLastAccessedKey(getCacheKey(mapping.key));
855924
}
856925
}
857926

@@ -1184,6 +1253,9 @@ const OnyxUtils = {
11841253
prepareKeyValuePairsForStorage,
11851254
applyMerge,
11861255
initializeWithDefaultKeyStates,
1256+
getCacheKey,
1257+
isComputedKey,
1258+
addDependentCacheKey,
11871259
};
11881260

11891261
export default OnyxUtils;

lib/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import Onyx from './Onyx';
22
import type {OnyxUpdate, ConnectOptions} from './Onyx';
3-
import type {CustomTypeOptions, OnyxCollection, OnyxEntry, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState, OnyxValue} from './types';
3+
import type {ComputedKey, CustomTypeOptions, OnyxCollection, OnyxEntry, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState, OnyxValue} from './types';
44
import type {UseOnyxResult, FetchStatus, ResultMetadata} from './useOnyx';
55
import useOnyx from './useOnyx';
66
import withOnyx from './withOnyx';
77

88
export default Onyx;
99
export {withOnyx, useOnyx};
1010
export type {
11+
ComputedKey,
1112
CustomTypeOptions,
1213
OnyxCollection,
1314
OnyxEntry,

0 commit comments

Comments
 (0)