Skip to content

Commit 341b0ea

Browse files
Computed values
1 parent f8ef6a1 commit 341b0ea

File tree

7 files changed

+340
-8
lines changed

7 files changed

+340
-8
lines changed

lib/Onyx.js

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ let lastConnectionID = 0;
3434
// Holds a mapping of all the react components that want their state subscribed to a store key
3535
const callbackToStateMapping = {};
3636

37+
// Holds a mapping of cache keys to their dependencies. This is used to invalidate computed keys.
38+
const dependentCacheKeys = {};
39+
3740
// Keeps a copy of the values of the onyx collection keys as a map for faster lookups
3841
let onyxCollectionKeyMap = new Map();
3942

@@ -143,6 +146,16 @@ const reduceCollectionWithSelector = (collection, selector, withOnyxInstanceStat
143146
{},
144147
);
145148

149+
/**
150+
* Returns if the key is a computed key.
151+
*
152+
* @param {Mixed} key
153+
* @returns {boolean}
154+
*/
155+
function isComputedKey(key) {
156+
return typeof key === 'object' && 'compute' in key;
157+
}
158+
146159
/**
147160
* Get some data from the store
148161
*
@@ -248,6 +261,10 @@ function isSafeEvictionKey(testKey) {
248261
return _.some(evictionAllowList, (key) => isKeyMatch(key, testKey));
249262
}
250263

264+
function getCacheKey(key) {
265+
return isComputedKey(key) ? key.cacheKey : key;
266+
}
267+
251268
/**
252269
* Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined.
253270
* If the requested key is a collection, it will return an object with all the collection members.
@@ -257,6 +274,30 @@ function isSafeEvictionKey(testKey) {
257274
* @returns {Mixed}
258275
*/
259276
function tryGetCachedValue(key, mapping = {}) {
277+
if (isComputedKey(key)) {
278+
// Check if we have the value in cache already.
279+
let val = cache.getValue(key.cacheKey);
280+
if (val !== undefined) {
281+
return val;
282+
}
283+
284+
// Check if we can compute the value if all dependencies are in cache.
285+
const dependencies = _.mapObject(key.dependencies || {}, (dependencyKey) =>
286+
tryGetCachedValue(
287+
dependencyKey,
288+
// TODO: We could support full mapping here.
289+
{key: dependencyKey},
290+
),
291+
);
292+
if (_.all(dependencies, (dependency) => dependency !== undefined)) {
293+
val = key.compute(dependencies);
294+
cache.set(key.cacheKey, val);
295+
return val;
296+
}
297+
298+
return undefined;
299+
}
300+
260301
let val = cache.getValue(key);
261302

262303
if (isCollectionKey(key)) {
@@ -404,6 +445,19 @@ function getCachedCollection(collectionKey) {
404445
);
405446
}
406447

448+
function clearComputedCacheForKey(key) {
449+
const dependentKeys = dependentCacheKeys[key];
450+
if (!dependentKeys) {
451+
return;
452+
}
453+
454+
dependentKeys.forEach((dependentKey) => {
455+
cache.drop(dependentKey);
456+
457+
clearComputedCacheForKey(dependentKey);
458+
});
459+
}
460+
407461
/**
408462
* When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
409463
*
@@ -414,6 +468,8 @@ function getCachedCollection(collectionKey) {
414468
* @param {boolean} [notifyWithOnyxSubscibers=true]
415469
*/
416470
function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) {
471+
clearComputedCacheForKey(collectionKey);
472+
417473
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
418474
// 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
419475
// 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().
@@ -590,6 +646,8 @@ function keyChanged(key, data, canUpdateSubscriber, notifyRegularSubscibers = tr
590646
removeLastAccessedKey(key);
591647
}
592648

649+
clearComputedCacheForKey(key);
650+
593651
// 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
594652
// 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
595653
// 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.
@@ -773,7 +831,7 @@ function addKeyToRecentlyAccessedIfNeeded(mapping) {
773831
throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`);
774832
}
775833

776-
addLastAccessedKey(mapping.key);
834+
addLastAccessedKey(getCacheKey(mapping.key));
777835
}
778836
}
779837

@@ -800,6 +858,15 @@ function getCollectionDataAndSendAsObject(matchingKeys, mapping) {
800858
.then((val) => sendDataToConnection(mapping, val, undefined, true));
801859
}
802860

861+
function computeAndSendData(mapping, dependencies) {
862+
let val = cache.getValue(mapping.key.cacheKey);
863+
if (val === undefined) {
864+
val = mapping.key.compute(dependencies);
865+
cache.set(mapping.key.cacheKey, val);
866+
}
867+
sendDataToConnection(mapping, val, mapping.key.cacheKey, true);
868+
}
869+
803870
/**
804871
* Subscribes a react component's state directly to a store key
805872
*
@@ -833,6 +900,48 @@ function connect(mapping) {
833900
callbackToStateMapping[connectionID] = mapping;
834901
callbackToStateMapping[connectionID].connectionID = connectionID;
835902

903+
if (isComputedKey(mapping.key)) {
904+
deferredInitTask.promise
905+
.then(() => addKeyToRecentlyAccessedIfNeeded(mapping))
906+
.then(() => {
907+
const mappingDependencies = mapping.key.dependencies || {};
908+
const dependenciesCount = _.size(mappingDependencies);
909+
if (dependenciesCount === 0) {
910+
// If we have no dependencies we can send the computed value immediately.
911+
computeAndSendData(mapping, {});
912+
} else {
913+
callbackToStateMapping[connectionID].dependencyConnections = [];
914+
915+
const dependencies = {};
916+
_.each(mappingDependencies, (dependency, mappingKey) => {
917+
// Create a mapping of dependent cache keys so when a key changes, all dependent keys
918+
// can also be cleared from the cache.
919+
const cacheKey = getCacheKey(dependency);
920+
dependentCacheKeys[cacheKey] = dependentCacheKeys[cacheKey] || new Set();
921+
dependentCacheKeys[cacheKey].add(mapping.key.cacheKey);
922+
923+
// Connect to dependencies.
924+
const dependencyConnection = connect({
925+
key: dependency,
926+
waitForCollectionCallback: true,
927+
callback: (value) => {
928+
dependencies[mappingKey] = value;
929+
930+
// Once all dependencies are ready, compute the value and send it to the connection.
931+
if (_.size(dependencies) === dependenciesCount) {
932+
computeAndSendData(mapping, dependencies);
933+
}
934+
},
935+
});
936+
937+
// Store dependency connections so we can disconnect them later.
938+
callbackToStateMapping[connectionID].dependencyConnections.push(dependencyConnection);
939+
});
940+
}
941+
});
942+
return connectionID;
943+
}
944+
836945
if (mapping.initWithStoredValues === false) {
837946
return connectionID;
838947
}
@@ -932,6 +1041,10 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) {
9321041
removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID);
9331042
}
9341043

1044+
if (callbackToStateMapping[connectionID].dependencyConnections) {
1045+
callbackToStateMapping[connectionID].dependencyConnections.forEach((id) => disconnect(id));
1046+
}
1047+
9351048
delete callbackToStateMapping[connectionID];
9361049
}
9371050

@@ -1724,6 +1837,7 @@ const Onyx = {
17241837
isClientManagerReady: ActiveClientManager.isReady,
17251838
isClientTheLeader: ActiveClientManager.isClientTheLeader,
17261839
subscribeToClientChange: ActiveClientManager.subscribeToClientChange,
1840+
getCacheKey,
17271841
};
17281842

17291843
/**

lib/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Onyx, {OnyxUpdate, ConnectOptions} from './Onyx';
2-
import {CustomTypeOptions, OnyxCollection, OnyxEntry} from './types';
2+
import {CustomTypeOptions, OnyxCollection, OnyxEntry, ComputedKey} from './types';
33
import withOnyx from './withOnyx';
44

55
export default Onyx;
6-
export {CustomTypeOptions, OnyxCollection, OnyxEntry, OnyxUpdate, withOnyx, ConnectOptions};
6+
export {CustomTypeOptions, OnyxCollection, OnyxEntry, OnyxUpdate, withOnyx, ConnectOptions, ComputedKey};

lib/types.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ type CollectionKey = `${CollectionKeyBase}${string}`;
9999
*/
100100
type OnyxKey = Key | CollectionKey;
101101

102+
/**
103+
* A computed key is a key that is not stored in the database, but instead is computed from other keys
104+
* and cached in memory. This is useful for expensive computations that are used in multiple places.
105+
*/
106+
type ComputedKey<DependenciesT, ValueT> = {
107+
/**
108+
* The cache key of the computed value.
109+
*/
110+
cacheKey: string;
111+
/**
112+
* Keys that this computed key depends on. The values of these keys will be passed to the compute function.
113+
* This will also cause the key to be recomputed whenever any of the dependencies value change.
114+
*/
115+
dependencies?: {[KeyT in keyof DependenciesT]: OnyxKey | ComputedKey<any, unknown>};
116+
/**
117+
* Compute the value for this computed key.
118+
*/
119+
compute: (params: DependenciesT) => ValueT;
120+
};
121+
102122
/**
103123
* Represents a mapping of Onyx keys to values, where keys are either normal or collection Onyx keys
104124
* and values are the corresponding values in Onyx's state.
@@ -226,4 +246,4 @@ type NullishObjectDeep<ObjectType extends object> = {
226246
*/
227247
type WithOnyxInstanceState<TOnyxProps> = (TOnyxProps & {loading: boolean}) | undefined;
228248

229-
export {CollectionKey, CollectionKeyBase, CustomTypeOptions, DeepRecord, Key, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, Selector, NullishDeep, WithOnyxInstanceState};
249+
export {CollectionKey, CollectionKeyBase, ComputedKey, CustomTypeOptions, DeepRecord, Key, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, Selector, NullishDeep, WithOnyxInstanceState};

lib/withOnyx.d.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {IsEqual} from 'type-fest';
2-
import {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types';
2+
import {CollectionKeyBase, ComputedKey, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types';
33

44
/**
55
* Represents the base mapping options between an Onyx key and the component's prop.
@@ -86,6 +86,25 @@ type BaseMappingFunctionKeyAndSelector<TComponentProps, TOnyxProps, TOnyxProp ex
8686
selector: Selector<TOnyxKey, TOnyxProps, TOnyxProps[TOnyxProp]>;
8787
};
8888

89+
/**
90+
* TODO
91+
*
92+
* @example
93+
* ```ts
94+
* // Onyx prop with computed key.
95+
* accountName: {
96+
* key: {
97+
* cacheKey: ONYXKEYS.COMPUTED.ACCOUNT_NAME,
98+
* dependencies: {account: ONYXKEYS.ACCOUNT},
99+
* compute: ({account}) => account.name,
100+
* },
101+
* },
102+
* ```
103+
*/
104+
type BaseMappingComputedKey<TComponentProps, TOnyxProps, TOnyxProp extends keyof TOnyxProps> = {
105+
key: ComputedKey<any, TOnyxProps[TOnyxProp]> | ((props: Omit<TComponentProps, keyof TOnyxProps> & Partial<TOnyxProps>) => ComputedKey<any, TOnyxProps[TOnyxProp]>);
106+
};
107+
89108
/**
90109
* Represents the mapping options between an Onyx key and the component's prop with all its possibilities.
91110
*/
@@ -95,6 +114,7 @@ type Mapping<TComponentProps, TOnyxProps, TOnyxProp extends keyof TOnyxProps, TO
95114
| BaseMappingKey<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey, OnyxEntry<KeyValueMapping[TOnyxKey]>>
96115
| BaseMappingStringKeyAndSelector<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey>
97116
| BaseMappingFunctionKeyAndSelector<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey>
117+
| BaseMappingComputedKey<TComponentProps, TOnyxProps, TOnyxProp>
98118
);
99119

100120
/**
@@ -106,6 +126,7 @@ type CollectionMapping<TComponentProps, TOnyxProps, TOnyxProp extends keyof TOny
106126
| BaseMappingKey<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey, OnyxCollection<KeyValueMapping[TOnyxKey]>>
107127
| BaseMappingStringKeyAndSelector<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey>
108128
| BaseMappingFunctionKeyAndSelector<TComponentProps, TOnyxProps, TOnyxProp, TOnyxKey>
129+
| BaseMappingComputedKey<TComponentProps, TOnyxProps, TOnyxProp>
109130
);
110131

111132
/**

lib/withOnyx.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) {
128128
// (eg. if a user switches chats really quickly). In this case, it's much more stable to always look at the changes to prevProp and prevState to derive the key.
129129
// The second case cannot be used all the time because the onyx data doesn't change the first time that `componentDidUpdate()` runs after loading. In this case,
130130
// the `mapping.previousKey` must be used for the comparison or else this logic never detects that onyx data could have changed during the loading process.
131-
const previousKey = isFirstTimeUpdatingAfterLoading ? mapping.previousKey : Str.result(mapping.key, {...prevProps, ...prevOnyxDataFromState});
132-
const newKey = Str.result(mapping.key, {...this.props, ...onyxDataFromState});
131+
const previousKey = Onyx.getCacheKey(isFirstTimeUpdatingAfterLoading ? mapping.previousKey : Str.result(mapping.key, {...prevProps, ...prevOnyxDataFromState}));
132+
const newKey = Onyx.getCacheKey(Str.result(mapping.key, {...this.props, ...onyxDataFromState}));
133133
if (previousKey !== newKey) {
134134
Onyx.disconnect(this.activeConnectionIDs[previousKey], previousKey);
135135
delete this.activeConnectionIDs[previousKey];

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

0 commit comments

Comments
 (0)