Skip to content

Conversation

@JKobrynski
Copy link
Contributor

@JKobrynski JKobrynski commented Jan 26, 2026

Details

Related Issues

GH_LINK Expensify/App#80091

Automated Tests

Unit tests covering the newly added features were added in this PR.

Manual Tests

This is a new feature and is not used in the E/App yet, so it can't be tested there.

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
MacOS: Desktop

@JKobrynski JKobrynski requested a review from VickyStash February 2, 2026 08:43
@JKobrynski JKobrynski marked this pull request as ready for review February 2, 2026 15:02
@JKobrynski JKobrynski requested a review from a team as a code owner February 2, 2026 15:02
@melvin-bot melvin-bot bot requested review from puneetlath and removed request for a team February 2, 2026 15:03
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2edcd51d05

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +463 to +468
function isRamOnlyKey(key: OnyxKey): boolean {
try {
const collectionKey = getCollectionKey(key);
// If collectionKey exists for a given key, check if it's a RAM-only key
return cache.isRamOnlyKey(collectionKey);
} catch {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor RAM-only collection members, not just collection keys

The new isRamOnlyKey only checks the collection key when the input is a collection member. If a caller includes a specific member key in ramOnlyKeys (as implied by the docstring “RAM-only collection member”), getCollectionKey succeeds and the function returns cache.isRamOnlyKey(collectionKey), ignoring the member entry. In that scenario, the member will still be persisted to storage, which violates the RAM-only contract for that key. Consider checking cache.isRamOnlyKey(key) as well when the key is a collection member so explicitly marked members are honored.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes sense to clarify @JKobrynski , either the docs should be updated or the individual keys should be updated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand this problem correctly, this function is tested and should work correctly for RAM-only keys, RAM-only collection keys, and RAM-only collection member keys, the tests pass for all cases listed above. I updated the comment so every supported key is listed and added some examples. Let me know if that's what you meant, if not I will change it, thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @JKobrynski , I don't understand the comment because tests are actually covering all possible scenarios.

Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough Review of RAM-only Key Support Implementation

Overall this is a well-structured PR with good test coverage. I've left several comments on specific areas. Here's a summary:

Comments Left

Issue Priority Description
#2 Medium Pre-existing storage data not cleaned up when keys transition to RAM-only
#4 Low O(n) filtering in multiSetWithRetry on every call
#5 Low remove() doesn't skip storage operations for RAM-only keys
#6 Medium Missing test for Onyx.merge() with RAM-only key
#7 Medium Missing test for clear() with RAM-only keys
#8 Info Memory accumulation for RAM-only keys should be documented
#10 Low Documentation could be clearer about collection member behavior

Not Commented (excluded per request)

  • Issue 1 (individual collection member RAM-only handling) - there's already an unresolved comment about this
  • Issue 3 (try-catch performance) - minor optimization
  • Issue 9 (DevTools action handling) - correctly implemented

Nice work on the implementation! The core functionality looks solid.

initDevTools(enableDevTools);

Storage.init();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 2: Missing handling for pre-existing storage data after upgrade

If a user upgrades their app and a key that was previously persisted is now marked as RAM-only, the stale data remains in storage indefinitely. While it won't be read (since Onyx will skip storage operations), it's wasted space.

Consider adding a cleanup step here that removes RAM-only keys from storage if they already exist:

cache.setRamOnlyKeys(new Set<OnyxKey>(ramOnlyKeys));

// Clean up any pre-existing RAM-only keys from storage
if (ramOnlyKeys.length > 0) {
    Storage.getAllKeys().then((keys) => {
        const keysToRemove = Array.from(keys).filter(key => OnyxUtils.isRamOnlyKey(key));
        if (keysToRemove.length > 0) {
            Storage.removeItems(keysToRemove);
        }
    });
}

This ensures that if a key transitions from persisted to RAM-only between app versions, the old data is cleaned up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I added the clean up logic and a unit test for it

]);
});

it('should not save a RAM-only collection to storage', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 6: Missing test for Onyx.merge() with RAM-only key

The tests cover set, multiSet, setCollection, mergeCollection, and Onyx.update(), but I don't see a dedicated test for Onyx.merge() with a RAM-only key.

The merge operation has its own code path in OnyxMerge/index.ts and OnyxMerge/index.native.ts, so it would be good to have explicit test coverage:

it('should not save a RAM-only key to storage when using merge', async () => {
    await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {someProperty: 'value'});

    expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({someProperty: 'value'});
    expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull();
});

it('should not save a RAM-only collection member to storage when using merge', async () => {
    const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`;
    await Onyx.merge(collectionMemberKey, {data: 'test'});

    expect(cache.get(collectionMemberKey)).toEqual({data: 'test'});
    expect(await StorageMock.getItem(collectionMemberKey)).toBeNull();
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks!

.filter((key) => !keysToPreserve.includes(key))
.filter((key) => !keysToPreserve.includes(key) && !OnyxUtils.isRamOnlyKey(key))
.reduce((obj: KeyValueMapping, key) => {
// eslint-disable-next-line no-param-reassign
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 7: Missing test for clear() with RAM-only keys that have defaults

This line filters out RAM-only keys from being reset to defaults during clear(). It would be good to have a test case that verifies:

  1. RAM-only keys with default values are NOT written to storage during clear()
  2. RAM-only keys in cache are properly reset to their default values (or remain undefined if no default)

Example test:

it('should handle RAM-only keys with defaults correctly during clear', async () => {
    // Set a value for RAM-only key
    await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'some value');
    
    await Onyx.clear();
    
    // Verify it's not in storage
    expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull();
    // Verify cache state based on whether there's a default
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

ONYXKEYS.RAM_ONLY_KEY_1,
ONYXKEYS.RAM_ONLY_KEY_2,
ONYXKEYS.COLLECTION.RAM_ONLY_KEY_2,
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 8: Memory consideration worth documenting

RAM-only keys are stored in the cache indefinitely. If an application creates many ephemeral RAM-only collection members (e.g., tempData_1, tempData_2, ..., tempData_10000), they will accumulate in memory until Onyx.clear() is called.

This is expected behavior, but it might be worth adding a note here like:

Note: RAM-only keys still consume memory and will remain in cache until explicitly cleared or until Onyx.clear() is called. Use them judiciously for truly ephemeral data.

This helps users understand that "RAM-only" means "in memory" not "automatically garbage collected".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

```

### Using RAM-only keys

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 10: Documentation could be clearer about collection behavior

The current docs say "It also works for collections" but it's not explicit about:

  1. Whether you can mark individual collection members as RAM-only (e.g., report_123)
  2. Or if you must mark the entire collection key (e.g., report_)

Looking at the implementation of isRamOnlyKey, it appears that marking a collection key makes ALL members of that collection RAM-only. It might be helpful to clarify this:

You can mark entire collections as RAM-only by including the collection key (e.g., ONYXKEYS.COLLECTION.TEMP_DATA). This will make all members of that collection RAM-only. Individual collection member keys cannot be selectively marked as RAM-only.

Or if individual members ARE supported, update the implementation and add an example like:

ramOnlyKeys: [
    ONYXKEYS.COLLECTION.TEMP_DATA,  // Entire collection is RAM-only
    `${ONYXKEYS.COLLECTION.REPORT}special123`,  // Just this one member
],

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue 4: O(n) filtering on every multiSet call

The new filter in multiSetWithRetry runs on every multiSet call, iterating through all key-value pairs. Each isRamOnlyKey call may also iterate through collection keys via getCollectionKey.

For large batch operations (e.g., syncing many reports), this compounds. While acceptable for now since RAM-only keys are expected to be rare, if performance becomes an issue in the future, consider:

  1. Caching a Set of collection key prefixes for O(1) prefix matching
  2. Or doing a single pass that both filters and builds the storage array

Low priority - just flagging for awareness.


Issue 5: Consider adding RAM-only check to remove()

When a RAM-only key is set to null (e.g., via Onyx.set(RAMONLY_KEY, null)), the code path goes through remove() which calls Storage.removeItem(). This attempts to remove a key from storage that was never stored there - it's wasteful though not incorrect.

Consider adding a RAM-only check in remove():

function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean): Promise<void> {
    cache.drop(key);
    scheduleSubscriberUpdate(key, undefined as OnyxValue<TKey>, undefined, isProcessingCollectionUpdate);
    
    if (isRamOnlyKey(key)) {
        return Promise.resolve();
    }
    
    return Storage.removeItem(key).then(() => undefined);
}

Low priority since the storage operation will just be a no-op.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added that additional condition to remove. About the multiSetWithRetry optimisation, do you think we should do it in this PR? If so, I can address it. If we'd be ok with doing this as a followup improvement, I can assign myself to it and take care of it as soon as I finish working on the higher priority Onyx issues. What would you prefer?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can confirm Issue 4 with Reassure – we already have a test for multiSet and it didn't report regressions, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct! However, the perf tests don't include RAM-only keys, we should probably add them right?

@JKobrynski JKobrynski requested a review from mountiny February 3, 2026 15:07
Comment on lines +59 to +68
// Clean up any pre-existing RAM-only keys from storage
if (ramOnlyKeys.length > 0) {
Storage.getAllKeys().then((storedKeys) => {
const keysToRemove = Array.from(storedKeys).filter((key) => OnyxUtils.isRamOnlyKey(key));
if (keysToRemove.length > 0) {
Storage.removeItems(keysToRemove);
}
});
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really get this... is this just temporary? Otherwise, how could RAM only keys be in storage at all?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's only temporary, then I think we need a better way of doing this. I think it should be done as an Onyx migration.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is temporary, it's for the scenario when you have a regular key and you decide to convert it into a RAM-only key - it needs to be removed from storage. It's not just about rolling out this new feature, that kind of conversion might happen anytime, so we need a permanent solution. Do you think we should change it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok the scenario @JKobrynski described is real but I agree that it's probably more appropriate to deal these scenarios with a migration file in Onyx. Since this piece of code will add some overhead to initialisation every single time, maybe it's better to just put a comment above ramOnlyKeys when declaring it in E/App code, that every addition there should be followed with a migration file.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Fabio, I agree. It's not the job of internal Onyx code to handle and respond to a consumer adding/removing RAM only keys from the config. I think it's up to the consumer to manage that.

// Clean up any pre-existing RAM-only keys from storage
if (ramOnlyKeys.length > 0) {
Storage.getAllKeys().then((storedKeys) => {
const keysToRemove = Array.from(storedKeys).filter((key) => OnyxUtils.isRamOnlyKey(key));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't keysToRemove the same as ramOnlyKeys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, we filter ramOnlyKeys that are saved in storage (they used to be regular keys if present in storage) and that filtered list is keysToRemove, so it's just a part of ramOnlyKeys. However, it might be a good idea to do the following:

Storage.removeItems(ramOnlyKeys);

That eliminates the need to loop through all keys in storage. What do you think? CC: @mountiny @fabioh8010

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented here.

const defaultKeyValuePairs = Object.entries(
Object.keys(defaultKeyStates)
.filter((key) => !keysToPreserve.includes(key))
.filter((key) => !keysToPreserve.includes(key) && !OnyxUtils.isRamOnlyKey(key))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why exclude the RAM only keys here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because defaultKeyValuePairs are passed to Storage.multiSet(defaultKeyValuePairs) couple of lines below, we want to avoid saving RAM-only keys to storage.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. Would you mind adding that as a code comment so that it's obvious to others?

enablePerformanceMetrics = false,
enableDevTools = true,
skippableCollectionMemberIDs = [],
ramOnlyKeys = [],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be sure to run the automated documentation script. I expected the docs to be updated with this parameter. It would also be great to give it some comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I ran the script and pushed the changes. I added this comment in the lib/types.ts file, do you think we should add anything else?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that looks good. Thanks!

@JKobrynski JKobrynski requested a review from tgolen February 4, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants