UNPKG

react-native-onyx

Version:

State management for React Native

375 lines (374 loc) 15.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TASK = void 0; const fast_equals_1 = require("fast-equals"); const bindAll_1 = __importDefault(require("lodash/bindAll")); const utils_1 = __importDefault(require("./utils")); const OnyxKeys_1 = __importDefault(require("./OnyxKeys")); /** * Stable frozen empty object used as the canonical value for empty collections. * Returning the same reference avoids unnecessary re-renders in useSyncExternalStore, * which relies on === equality to detect changes. */ const FROZEN_EMPTY_COLLECTION = Object.freeze({}); // Task constants const TASK = { GET: 'get', GET_ALL_KEYS: 'getAllKeys', CLEAR: 'clear', }; exports.TASK = TASK; /** * In memory cache providing data by reference * Encapsulates Onyx cache related functionality */ class OnyxCache { constructor() { /** List of keys that are safe to remove when we reach max storage */ this.evictionAllowList = []; /** List of keys that have been directly subscribed to or recently modified from least to most recent */ this.recentlyAccessedKeys = new Set(); this.storageKeys = new Set(); this.nullishStorageKeys = new Set(); this.storageMap = {}; this.pendingPromises = new Map(); this.collectionSnapshots = new Map(); this.dirtyCollections = new Set(); // bind all public methods to prevent problems with `this` (0, bindAll_1.default)(this, 'getAllKeys', 'get', 'hasCacheForKey', 'addKey', 'addNullishStorageKey', 'hasNullishStorageKey', 'clearNullishStorageKeys', 'set', 'drop', 'merge', 'hasPendingTask', 'getTaskPromise', 'captureTask', 'setAllKeys', 'setEvictionAllowList', 'isEvictableKey', 'removeLastAccessedKey', 'addLastAccessedKey', 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', 'hasValueChanged', 'getCollectionData'); } /** Get all the storage keys */ getAllKeys() { return this.storageKeys; } /** * Allows to set all the keys at once. * This is useful when we are getting * all the keys from the storage provider * and we want to keep the cache in sync. * * Previously, we had to call `addKey` in a loop * to achieve the same result. * * @param keys - an array of keys */ setAllKeys(keys) { this.storageKeys = new Set(keys); for (const key of keys) { OnyxKeys_1.default.registerMemberKey(key); } } /** Saves a key in the storage keys list * Serves to keep the result of `getAllKeys` up to date */ addKey(key) { this.storageKeys.add(key); OnyxKeys_1.default.registerMemberKey(key); } /** Used to set keys that are null/undefined in storage without adding null to the storage map */ addNullishStorageKey(key) { this.nullishStorageKeys.add(key); } /** Used to set keys that are null/undefined in storage without adding null to the storage map */ hasNullishStorageKey(key) { return this.nullishStorageKeys.has(key); } /** Used to clear keys that are null/undefined in cache */ clearNullishStorageKeys() { this.nullishStorageKeys = new Set(); } /** Check whether cache has data for the given key */ hasCacheForKey(key) { return this.storageMap[key] !== undefined || this.hasNullishStorageKey(key); } /** Get a cached value from storage */ get(key) { return this.storageMap[key]; } /** * Set's a key value in cache * Adds the key to the storage keys list as well */ set(key, value) { this.addKey(key); // When a key is explicitly set in cache, we can remove it from the list of nullish keys, // since it will either be set to a non nullish value or removed from the cache completely. this.nullishStorageKeys.delete(key); const collectionKey = OnyxKeys_1.default.getCollectionKey(key); const oldValue = this.storageMap[key]; if (value === null || value === undefined) { delete this.storageMap[key]; if (collectionKey && oldValue !== undefined) { this.dirtyCollections.add(collectionKey); } return undefined; } this.storageMap[key] = value; if (collectionKey && oldValue !== value) { this.dirtyCollections.add(collectionKey); } return value; } /** Forget the cached value for the given key */ drop(key) { delete this.storageMap[key]; const collectionKey = OnyxKeys_1.default.getCollectionKey(key); if (collectionKey) { this.dirtyCollections.add(collectionKey); } // If this is a collection key, clear its snapshot if (OnyxKeys_1.default.isCollectionKey(key)) { this.collectionSnapshots.delete(key); } this.storageKeys.delete(key); OnyxKeys_1.default.deregisterMemberKey(key); } /** * Deep merge data to cache, any non existing keys will be created * @param data - a map of (cache) key - values */ merge(data) { if (typeof data !== 'object' || Array.isArray(data)) { throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs'); } const affectedCollections = new Set(); for (const [key, value] of Object.entries(data)) { this.addKey(key); const collectionKey = OnyxKeys_1.default.getCollectionKey(key); if (value === undefined) { this.addNullishStorageKey(key); // undefined means "no change" — skip storageMap modification continue; } if (value === null) { this.addNullishStorageKey(key); delete this.storageMap[key]; if (collectionKey) { affectedCollections.add(collectionKey); } } else { this.nullishStorageKeys.delete(key); // Per-key merge instead of spreading the entire storageMap const existing = this.storageMap[key]; const merged = utils_1.default.fastMerge(existing, value, { shouldRemoveNestedNulls: true, objectRemovalMode: 'replace', }).result; // fastMerge is reference-stable: returns the original target when // nothing changed, so a simple === check detects no-ops. if (merged === existing) { continue; } this.storageMap[key] = merged; if (collectionKey) { affectedCollections.add(collectionKey); } } } // Mark affected collections as dirty — snapshots will be lazily rebuilt on next read for (const collectionKey of affectedCollections) { this.dirtyCollections.add(collectionKey); } } /** * Check whether the given task is already running * @param taskName - unique name given for the task */ hasPendingTask(taskName) { return this.pendingPromises.get(taskName) !== undefined; } /** * Use this method to prevent concurrent calls for the same thing * Instead of calling the same task again use the existing promise * provided from this function * @param taskName - unique name given for the task */ getTaskPromise(taskName) { return this.pendingPromises.get(taskName); } /** * Capture a promise for a given task so other caller can * hook up to the promise if it's still pending * @param taskName - unique name for the task */ captureTask(taskName, promise) { const returnPromise = promise.finally(() => { this.pendingPromises.delete(taskName); }); this.pendingPromises.set(taskName, returnPromise); return returnPromise; } /** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */ hasValueChanged(key, value) { const currentValue = this.storageMap[key]; if (currentValue === value) { return false; } return !(0, fast_equals_1.deepEqual)(currentValue, value); } /** * Sets the list of keys that are considered safe for eviction * @param keys - Array of OnyxKeys that are safe to evict */ setEvictionAllowList(keys) { this.evictionAllowList = keys; } /** * Checks to see if this key has been flagged as safe for removal. * @param testKey - Key to check */ isEvictableKey(testKey) { return this.evictionAllowList.some((key) => OnyxKeys_1.default.isKeyMatch(key, testKey)); } /** * Remove a key from the recently accessed key list. */ removeLastAccessedKey(key) { this.recentlyAccessedKeys.delete(key); } /** * Add a key to the list of recently accessed keys. The least * recently accessed key should be at the head and the most * recently accessed key at the tail. */ addLastAccessedKey(key, isCollectionKey) { // Only specific keys belong in this list since we cannot remove an entire collection. if (isCollectionKey || !this.isEvictableKey(key)) { return; } this.removeLastAccessedKey(key); this.recentlyAccessedKeys.add(key); } /** * Take all the keys that are safe to evict and add them to * the recently accessed list when initializing the app. This * enables keys that have not recently been accessed to be * removed. * @param isCollectionKeyFn - Function to determine if a key is a collection key * @param getAllKeysFn - Function to get all keys, defaults to Storage.getAllKeys */ addEvictableKeysToRecentlyAccessedList(isCollectionKeyFn, getAllKeysFn) { return getAllKeysFn().then((keys) => { for (const evictableKey of this.evictionAllowList) { for (const key of keys) { if (!OnyxKeys_1.default.isKeyMatch(evictableKey, key)) { continue; } this.addLastAccessedKey(key, isCollectionKeyFn(key)); } } }); } /** * Finds the least recently accessed key that can be safely evicted from storage. * `excludeKeys` skips keys that must not be evicted (e.g. the in-flight write's own keys, * whose cache value is the merge base the retry depends on). */ getKeyForEviction(excludeKeys) { // recentlyAccessedKeys is ordered from least to most recently accessed, // so the first non-excluded key is the best candidate for eviction. for (const key of this.recentlyAccessedKeys) { if (!(excludeKeys === null || excludeKeys === void 0 ? void 0 : excludeKeys.has(key))) { return key; } } return undefined; } /** * Set the collection keys for optimized storage */ setCollectionKeys(collectionKeys) { OnyxKeys_1.default.setCollectionKeys(collectionKeys); // Initialize frozen snapshots for collection keys for (const collectionKey of collectionKeys) { if (!this.collectionSnapshots.has(collectionKey)) { this.collectionSnapshots.set(collectionKey, Object.freeze({})); } } } /** * Rebuilds the frozen collection snapshot from current storageMap references. * Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys). * Returns the previous snapshot reference when all member references are identical, * preventing unnecessary re-renders in useSyncExternalStore. * * @param collectionKey - The collection key to rebuild */ rebuildCollectionSnapshot(collectionKey) { const previousSnapshot = this.collectionSnapshots.get(collectionKey); const members = {}; let hasMemberChanges = false; // Use the indexed forward lookup for O(collectionMembers) iteration. // Falls back to scanning all storageKeys if the index isn't populated yet. const memberKeys = OnyxKeys_1.default.getMembersOfCollection(collectionKey); const keysToScan = memberKeys !== null && memberKeys !== void 0 ? memberKeys : this.storageKeys; const needsPrefixCheck = !memberKeys; for (const key of keysToScan) { // When using the fallback path (scanning all storageKeys instead of the indexed // forward lookup), skip keys that don't belong to this collection. if (needsPrefixCheck && OnyxKeys_1.default.getCollectionKey(key) !== collectionKey) { continue; } const val = this.storageMap[key]; // Skip null/undefined values — they represent deleted or unset keys // and should not be included in the frozen collection snapshot. if (val !== undefined && val !== null) { members[key] = val; // Check if this member's reference changed from the old snapshot if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) { hasMemberChanges = true; } } } // Check if any members were removed from the previous snapshot. // We can't rely on count comparison alone — if one key is removed and another added, // the counts match but the snapshot content is different. if (!hasMemberChanges && previousSnapshot) { // eslint-disable-next-line no-restricted-syntax for (const key in previousSnapshot) { if (!(key in members)) { hasMemberChanges = true; break; } } } // If nothing actually changed, reuse the old snapshot reference. // This is critical: useSyncExternalStore uses === to detect changes, // so returning the same reference prevents unnecessary re-renders. if (!hasMemberChanges && previousSnapshot) { return; } Object.freeze(members); this.collectionSnapshots.set(collectionKey, members); } /** * Get all data for a collection key. * Returns a frozen snapshot with structural sharing — safe to return by reference. * Lazily rebuilds the snapshot if the collection was modified since the last read. */ getCollectionData(collectionKey) { if (this.dirtyCollections.has(collectionKey)) { this.rebuildCollectionSnapshot(collectionKey); this.dirtyCollections.delete(collectionKey); } const snapshot = this.collectionSnapshots.get(collectionKey); if (utils_1.default.isEmptyObject(snapshot)) { // We check storageKeys.size (not collection-specific keys) to distinguish // "init complete, this collection is genuinely empty" from "init not done yet." // During init, setAllKeys loads ALL keys at once — so if any key exists, // the full storage picture is loaded and an empty collection is truly empty. // Returning undefined before init prevents subscribers from seeing a false empty state. if (this.storageKeys.size > 0) { return FROZEN_EMPTY_COLLECTION; } return undefined; } return snapshot; } } const instance = new OnyxCache(); exports.default = instance;