Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions lib/OnyxCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ class OnyxCache {
/** Cache of complete collection data objects for O(1) retrieval */
private collectionData: Record<OnyxKey, Record<OnyxKey, OnyxValue<OnyxKey>>>;

/** Track which collections have changed since last getCollectionData() call */
private dirtyCollections: Set<OnyxKey>;

/** The stable reference returned last time for each collection */
private stableCollectionReference: Record<OnyxKey, Record<OnyxKey, OnyxValue<OnyxKey>> | undefined>;

/**
* Captured pending tasks for already running storage methods
* Using a map yields better performance on operations such a delete
Expand All @@ -55,13 +61,23 @@ class OnyxCache {
/** Set of collection keys for fast lookup */
private collectionKeys = new Set<OnyxKey>();

/**
* Mark a collection as dirty when its data changes
* This ensures getCollectionData() creates a new reference
*/
private markCollectionDirty(collectionKey: OnyxKey): void {
this.dirtyCollections.add(collectionKey);
}

constructor() {
this.storageKeys = new Set();
this.nullishStorageKeys = new Set();
this.recentKeys = new Set();
this.storageMap = {};
this.collectionData = {};
this.pendingPromises = new Map();
this.dirtyCollections = new Set();
this.stableCollectionReference = {};

// bind all public methods to prevent problems with `this`
bindAll(
Expand Down Expand Up @@ -174,6 +190,7 @@ class OnyxCache {
// Remove from collection data cache if it's a collection member
if (collectionKey && this.collectionData[collectionKey]) {
delete this.collectionData[collectionKey][key];
this.markCollectionDirty(collectionKey);
}
return undefined;
}
Expand All @@ -186,6 +203,7 @@ class OnyxCache {
this.collectionData[collectionKey] = {};
}
this.collectionData[collectionKey][key] = value;
this.markCollectionDirty(collectionKey);
}

return value;
Expand All @@ -199,11 +217,14 @@ class OnyxCache {
const collectionKey = this.getCollectionKey(key);
if (collectionKey && this.collectionData[collectionKey]) {
delete this.collectionData[collectionKey][key];
this.markCollectionDirty(collectionKey);
}

// If this is a collection key, clear its data
if (this.isCollectionKey(key)) {
delete this.collectionData[key];
this.dirtyCollections.delete(key);
delete this.stableCollectionReference[key];
}

this.storageKeys.delete(key);
Expand Down Expand Up @@ -238,6 +259,7 @@ class OnyxCache {
// Remove from collection data cache if it's a collection member
if (collectionKey && this.collectionData[collectionKey]) {
delete this.collectionData[collectionKey][key];
this.markCollectionDirty(collectionKey);
}
} else {
this.nullishStorageKeys.delete(key);
Expand All @@ -248,6 +270,7 @@ class OnyxCache {
this.collectionData[collectionKey] = {};
}
this.collectionData[collectionKey][key] = this.storageMap[key];
this.markCollectionDirty(collectionKey);
}
}
});
Expand Down Expand Up @@ -323,6 +346,7 @@ class OnyxCache {
const collectionKey = this.getCollectionKey(key);
if (collectionKey && this.collectionData[collectionKey]) {
delete this.collectionData[collectionKey][key];
this.markCollectionDirty(collectionKey);
}
this.recentKeys.delete(key);
}
Expand Down Expand Up @@ -440,6 +464,8 @@ class OnyxCache {
return;
}
this.collectionData[collectionKey] = {};
// New empty collection - mark as dirty so first getCollectionData creates reference
this.markCollectionDirty(collectionKey);
});
}

Expand All @@ -464,15 +490,26 @@ class OnyxCache {

/**
* Get all data for a collection key
* Returns stable references when data hasn't changed to prevent unnecessary rerenders
*/
getCollectionData(collectionKey: OnyxKey): Record<OnyxKey, OnyxValue<OnyxKey>> | undefined {
if (!this.dirtyCollections.has(collectionKey) && this.stableCollectionReference[collectionKey]) {
return this.stableCollectionReference[collectionKey];
}

const cachedCollection = this.collectionData[collectionKey];
if (!cachedCollection || Object.keys(cachedCollection).length === 0) {
this.dirtyCollections.delete(collectionKey);
delete this.stableCollectionReference[collectionKey];
return undefined;
}

// Return a shallow copy to ensure React detects changes when items are added/removed
return {...cachedCollection};
// Collection changed, create new reference
const newReference = {...cachedCollection};
this.stableCollectionReference[collectionKey] = newReference;
this.dirtyCollections.delete(collectionKey);

return newReference;
}
}

Expand Down
55 changes: 55 additions & 0 deletions tests/perf-test/OnyxCache.perf-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,59 @@ describe('OnyxCache', () => {
});
});
});

describe('getCollectionData', () => {
test('one call getting collection with stable reference (10k members)', async () => {
await measureFunction(() => cache.getCollectionData(collectionKey), {
beforeEach: async () => {
resetCacheBeforeEachMeasure();
cache.setCollectionKeys(new Set([collectionKey]));
// Set all collection members
Object.entries(mockedReportActionsMap).forEach(([k, v]) => cache.set(k, v));
// First call to create stable reference
cache.getCollectionData(collectionKey);
},
});
});

test('one call getting collection with dirty collection (10k members)', async () => {
await measureFunction(() => cache.getCollectionData(collectionKey), {
beforeEach: async () => {
resetCacheBeforeEachMeasure();
cache.setCollectionKeys(new Set([collectionKey]));
// Set all collection members
Object.entries(mockedReportActionsMap).forEach(([k, v]) => cache.set(k, v));
// Mark collection as dirty by updating a member
cache.set(mockedReportActionsKeys[0], createRandomReportAction(Number(mockedReportActionsMap[mockedReportActionsKeys[0]].reportActionID)));
},
});
});

test('one call getting collection among 1000 different collections', async () => {
const targetCollectionKey = `collection_100_`;

await measureFunction(() => cache.getCollectionData(targetCollectionKey), {
beforeEach: async () => {
resetCacheBeforeEachMeasure();

// Create 500 collection keys
const collectionKeys = Array.from({length: 1000}, (_, i) => `collection_${i}_`);
cache.setCollectionKeys(new Set(collectionKeys));

// Populate each collection with mockedReportActionsMap data
const partOfMockedReportActionsKeys = mockedReportActionsKeys.slice(0, 20);
collectionKeys.forEach((colKey) => {
partOfMockedReportActionsKeys.forEach((originalKey) => {
const memberId = originalKey.replace(collectionKey, '');
const memberKey = `${colKey}${memberId}`;
cache.set(memberKey, mockedReportActionsMap[originalKey]);
});
});

// First call to create stable reference
cache.getCollectionData(targetCollectionKey);
},
});
});
});
});
Loading