Skip to content

Drop keys removed in another tab instead of leaving them indexed with no value (web) - #884

Open
LukasMod wants to merge 6 commits into
Expensify:mainfrom
callstack-internal:fix/mark-removed-keys-nullish
Open

LukasMod wants to merge 6 commits into
Expensify:mainfrom
callstack-internal:fix/mark-removed-keys-nullish

Conversation

@LukasMod

@LukasMod LukasMod commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Details

Follow-up to #863

On web, a key removed in another tab reaches this tab through the sync handler as cache.set(key, undefined). That empties the value but keeps the key in the index. Every local removal uses cache.drop(key), which also takes the key out of the index. The cache-miss diagnostic from #863 treats "indexed, no value" as a lost value: it reads storage, logs if the other tab has already written the key back, and spends the collection's one report for the session either way.

Production logs about 7,200 [Onyx] Cache held no value for a key storage still has lines per working day (average of the five weekdays Oct 28 to Nov 3), all from web browsers. A two-tab run with extra logging traced every hit to the other tab removing a key and writing it back, or removing it twice. None was a lost value.

One change in the sync handler in lib/Onyx.ts: a null or undefined synced value calls cache.drop(key), the same as a local removal. The key leaves the index and the diagnostic skips it. Subscribers see the same undefined they saw before. Only the index and the collection member registry change, the way they already do for local removals.

A key already marked as known empty stays known empty after the removal, because drop does not touch that mark and set did. That matches OnyxUtils.remove and Onyx.merge(key, null).

Not in scope: a known edge case in the sync receiver. It reads a key from disk and applies what it finds, so if this tab wrote the same key a moment earlier and that write is still on its way to disk, the older value can land over the newer one. This PR does not change that; it only means such a case now looks like a plain removal instead of a cache miss. I tried logging it and the only hits were sign-out cleanup, both tabs clearing and recomputing derived values, so I dropped the log. If it ever shows up as a real symptom, the fix is in the receiver: skip or re-read a synced key while a local write to it is pending.

Related Issues

Expensify/App#101910
Follow-up to #863

Linked E/App PR

Expensify/App#105902

Automated Tests

tests/unit/OnyxSubscriptionManagerTest.ts, in the cache miss reporting group:

  • a key another tab removed causes no storage read and no log. Fails on main.
  • a real miss on another member of the same collection still logs once after that. Fails on main, where the removal spends the collection's report.

tests/unit/useOnyxTest.ts: useOnyx on a key another tab removed, with a merge pending, still reports loading and then the merged value. Passes on main too; it pins that hook behavior did not change.

Full suite: 774 tests passing. Lint and tsc --noEmit clean.

Manual Tests

Web, two tabs on one account, console open with Verbose level. In every test, neither tab should log [Onyx] Cache held no value for a key storage still has.

Basic flow

  1. Sign in on tab A and tab B, open the same chat in both.
  2. Send a few messages from tab A, react to one, delete one.
  3. Sign out in tab A, then sign back in.
  4. Confirm both tabs still render the chat list and the messages.

Remove then re-add the same key

  1. In tab A, add a reaction to a message, remove it, add it again.
  2. In tab A, delete a message, then send a new one in the same chat.
  3. Confirm tab B shows the final reaction, the deletion and the new message without a reload, and the chat preview in the LHN matches.

Collection shrinks from the other tab

  1. In tab A, leave a group chat or delete a workspace, then archive a chat.
  2. Confirm tab B drops each item from the LHN and the workspace list, with no empty row left and nothing stale in search.
  3. In tab A, create a new chat or workspace, confirm it appears in tab B.

Offline queue across tabs

  1. In tab A, go offline in DevTools, send three messages, go back online.
  2. Confirm tab B shows the three messages once, with no stuck sending state and no duplicates.

Expense created and deleted from the other tab

  1. In tab A, create an expense and write a message in its thread.
  2. Confirm tab B shows the same expense and message.
  3. In tab A, delete the expense.
  4. Confirm tab B shows the deletion.

Removal while the other tab is writing the same key

  1. In tab B, open an expense and go into the amount editor. In tab A, delete that expense. In tab B, save the amount.
  2. Confirm tab B shows no stuck loading state, and after a reload both tabs agree the expense is gone.
  3. In both tabs, react to the same message within a second of each other, then remove the reaction in tab A.
  4. Confirm tab B shows the final reaction state with no stuck loading state.

Sign-out during the other tab's reload

  1. In tab A, throttle CPU 4x in DevTools and reload. While it is still loading, sign out in tab B.
  2. Confirm both tabs end on the sign-in screen with no console errors.

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I linked the corresponding Expensify/App PR in the ### Linked E/App PR section above, and verified this change against it (E/App CI passed and manual testing completed)
  • 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
  • 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

N/A

Android: mWeb Chrome
android-web.mov
iOS: Native

N/A

iOS: mWeb Safari
ios-web.mov
MacOS: Chrome / Safari
web.mov
test.2.mov
test3.mov
test4-5.mov
signout.mov

@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from 5e70e90 to 54d9cc0 Compare October 30, 2026 16:16
@LukasMod LukasMod mentioned this pull request Oct 30, 2026
42 tasks done
@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch 2 times, most recently from 32b3733 to 9306e4f Compare October 30, 2026 16:20
@LukasMod

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T10:34:51.359866Z cb8bdd5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from 9306e4f to db151fe Compare November 2, 2026 10:00
@LukasMod LukasMod changed the title Mark keys set to null in cache as known empty and keep in-flight local writes on sync Mark keys removed in another tab or missing in storage as known empty Nov 2, 2026
@LukasMod LukasMod changed the title Mark keys removed in another tab or missing in storage as known empty Drop keys removed in another tab instead of leaving them indexed with no value Nov 2, 2026
@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from db6a92f to a8e806b Compare November 3, 2026 13:51
@TaduJR

TaduJR commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Assignment here @chuckdries

Thanks!

@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from a8e806b to b7751b0 Compare November 3, 2026 15:45
@LukasMod

LukasMod commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: b7751b0321

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from 4e82545 to 0905b80 Compare November 4, 2026 10:22
@LukasMod
LukasMod force-pushed the fix/mark-removed-keys-nullish branch from 8881b39 to cb8bdd5 Compare November 4, 2026 10:37
@LukasMod

LukasMod commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: cb8bdd5c6d

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@LukasMod LukasMod changed the title Drop keys removed in another tab instead of leaving them indexed with no value Drop keys removed in another tab instead of leaving them indexed with no value (web) Nov 4, 2026

@fabioh8010 fabioh8010 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@LukasMod Could you write and verify more manual tests for tab syncing? Just to make sure the change is safe.

@LukasMod

LukasMod commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

@fabioh8010 I added 4 more tests, looks okay. I didn't spot issues

@LukasMod
LukasMod marked this pull request as ready for review November 5, 2026 10:31
@LukasMod
LukasMod requested a review from a team as a code owner November 5, 2026 10:31
@melvin-bot
melvin-bot Bot requested review from stitesExpensify and removed request for a team November 5, 2026 10:32

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

Copy link
Copy Markdown

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: cb8bdd5c6d

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/Onyx.ts
Logger.logHmmm(`Sync removed a key while this tab was writing it: ${key}`);
}
// Drop like a local removal, so the key leaves the index instead of lingering as an indexed key with no value.
cache.drop(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the nullish marker before dropping synced keys

When a key already has a nullish marker (for example, an IndexedDB multiGet returned undefined for an absent member), cache.drop() removes the key from the index but does not clear that marker, so hasCacheForKey() still returns true. If a merge to that key is pending when a new useOnyx subscriber mounts, the hook treats the key as cached and reports loaded instead of remaining loading until the merge lands; the added test covers only the case without a nullish marker. The previous cache.set(key, null) path cleared it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You’re right that drop keeps the marker where set cleared it. That’s intended. The marker means “storage has nothing for this key”, and that’s still true after the other tab removed it. All local removals (remove, merge(key, null), eviction) keep it the same way, so this just makes the sync path match them. The hook already reported loaded for a marked key before the removal arrived, so nothing changes for it. On web this state is nearly impossible to reach anyway

Comment thread lib/Onyx.ts Outdated
Comment on lines +82 to +84
if (PendingWrites.hasPendingWriteForKey(key)) {
Logger.logHmmm(`Sync removed a key while this tab was writing it: ${key}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This new log line also fires when nothing is lost

trackPendingWrite registers a write when it is called, before afterInit and merge batching apply it to the cache.

return PendingWrites.trackPendingWrite(
.

So the check is also true when:

  • this tab's merge is still queued, applies after the drop and wins
  • this tab's own pending set(key, null) overlaps the other tab's removal
  • the other tab signs out and Onyx.clear removes every key, giving one line per key with no per-session cap (the Fix cache-miss diagnostic for undefined keys #863 diagnostic caps via reportedCacheMisses)

The new test is the first case. A non-zero count would not prove the race happened.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on all three, and I reproduced the third: a sign-out in one tab while the other reloads logged about thirty lines, all derived keys being recomputed while the other tab’s clear removed them. Nothing was lost, both tabs end up signed out, so the line was reporting cleanup, not the race. Removed the line and the helper. This PR is the drop fix only

Comment on lines +465 to +476
it('should log once when another tab removes a key this tab is still writing', async () => {
const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls.at(-1)?.[0];
expect(syncCallback).toBeDefined();

await Onyx.set(MEMBER_1, {id: 1});
const mergePromise = Onyx.merge(MEMBER_1, {id: 2});
syncCallback([[MEMBER_1, undefined]]);
await mergePromise;

expect(logHmmmSpy).toHaveBeenCalledTimes(1);
expect(logHmmmSpy).toHaveBeenCalledWith(expect.stringContaining(MEMBER_1));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "log once" test does not check the outcome

It only counts logHmmm calls and never asserts the value left after the removal and the merge collide.

Fix: add expect(await Onyx.get(MEMBER_1)).toEqual({id: 2}).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gone together with the log

Comment on lines +430 to +431
const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls.at(-1)?.[0];
expect(syncCallback).toBeDefined();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sync callback setup repeated in every new test

The same two lines open all four new tests (lines 430, 447, 466, 479).

Fix: a typed getSyncCallback() helper next to forgetValueButKeepKey.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added getSyncCallback() next to forgetValueButKeepKey, the two remaining tests use it

Comment thread lib/Onyx.ts Outdated
Comment on lines +79 to +81
// Capture the previous cached value before cache.set() so notifyCollection() can diff old vs new per member.
const previousValue = isCollectionMember ? cache.get(key) : undefined;
cache.set(key, value);
if (value === null || value === undefined) {

@aimane-chnaif aimane-chnaif Nov 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

// Capture the previous cached value before cache.set() so notifyCollection() can diff old vs new per member.

This comment is out-of-date.
It still says "before cache.set()", but the next line can now call cache.drop().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, it now says “before the cache changes”

@TaduJR

TaduJR commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified any copy / text that was added to the app is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • 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
    • 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 the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • 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 PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • 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 reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari

@TaduJR

TaduJR commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Bump @chuckdries on this one #884 (comment) as we are coming from follow-up of this review #863 (review)

Thanks!

@TaduJR TaduJR left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@LukasMod Thanks.

The drop does fix the log noise: no false alarm, and a later real miss still logs. But I don't think it should merge on its own.

A local removal is always current. A sync answer isn't, because it can be older than a write this tab still has in flight. If tab A writes a key right after its sync read starts, the drop takes the key out of the index while the write still lands in storage.

In Real IndexedDB, tab B removes the key and tab A's write queues behind its sync read:

After the race main #884
Onyx.clear row removed row stays
mergeCollection({c: 3}) on stored {a, b} {a, b, c} {c}
multiSet or mergeCollection with null row removed row stays
setCollection without that member row removed row stays

The clear row also reproduces in two real tabs, so a value can survive sign-out. That means "This PR does not change that. It only means such a case now looks like a plain removal instead of a cache miss" isn't right.

I forced the order to make it repeatable. Without forcing it, with tab A writing the same key 0 to 6 ms after tab B's removal, 3 of 120 rounds left a row with no index entry on #884, and 0 on main. I can't say how often this happens in production. Your log run saw this overlap at sign-out, but that log also fired when nothing was lost.

The race itself is already on main, and it loses data there too. The cache loses the value, so a later Onyx.merge builds on nothing and overwrites the row: stored {a, b} becomes {c}, on both main and #884.

Your description already names the fix: "skip or re-read a synced key while a local write to it is pending". I tried both, and I'd go with re-read. Skip can leave a tab holding a value storage no longer has, after a real removal during a pending batch. Re-read fixes every row above plus the merge case, 0 of 120 rounds, and your drop stays as it is: commit on top of this PR (+52/−6 in lib, including a small Storage.isMemoryOnly). I didn't test constant writes to one key, or a re-read that a newer write only partly overtakes.

If you'd rather ship #884 first, this version keeps cache.set and marks the key so only the diagnostic skips it. It matches main on every row above, and the race stays open as it is today.

One more difference from main: after this race, main logs Cache held no value for a key storage still has once, and #884 logs nothing, because the key has left the index. Your #863 test calls that log the only trace of a lost value. The marker version is silent here too. The re-read has nothing to log, because the cache keeps the value.


expect(logHmmmSpy).toHaveBeenCalledTimes(1);
expect(logHmmmSpy).toHaveBeenCalledWith(expect.stringContaining(COLLECTION));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test for the clear row. It fails here with Received: "mine", and passes on main and on both versions above.

        it('should still delete a key on Onyx.clear when a removal answer lands while this tab is writing it', async () => {
            // Given another indexed key, and a write to this key still on its way to storage
            const syncCallback = getSyncCallback();
            await Onyx.set(ONYXKEYS.OTHER_TEST, 'other');
            const setItemMock = jest.mocked(StorageMock.setItem);
            const writeToStorage = setItemMock.getMockImplementation();
            const storageWrite = createDeferredTask();
            setItemMock.mockImplementationOnce((key, value) => storageWrite.promise.then(() => writeToStorage?.(key, value)));
            const write = Onyx.set(ONYXKEYS.TEST_KEY, 'mine');
            await waitForPromisesToResolve();

            // When the removal answer arrives before the write lands, and this tab then clears
            syncCallback([[ONYXKEYS.TEST_KEY, undefined]]);
            storageWrite.resolve();
            await write;
            await Onyx.clear();

            // Then the row doesn't survive into the next session
            expect(await StorageMock.getItem(ONYXKEYS.TEST_KEY)).toBeNull();
        });

Comment thread lib/Onyx.ts
cache.set(key, value);
if (value === null || value === undefined) {
// Drop like a local removal, so the key leaves the index instead of lingering as an indexed key with no value.
cache.drop(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This answer can be older than a write still in flight here. Core of the re-read (full change in the commit linked above):

+                const isRemoved = value === null || value === undefined;
+
+                // A removal, or any re-read answer, may predate a write still in flight, and acting on it would lose that write, so ask storage again once the writes settle.
+                if ((isRemoved || isReread) && PendingWrites.getPendingWritesForKey(key).length > 0) {
+                    keysToReread.push(key);
+                    continue;
+                }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you help me understand why this is necessary? Just to prevent an old in-flight write from re-populating the dropped key?

@chuckdries

Copy link
Copy Markdown
Contributor

I'm stealing the review on this one since I have context on the prior PRs and the project

@chuckdries
chuckdries requested review from chuckdries and removed request for stitesExpensify November 5, 2026 15:40
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.

5 participants