Skip to content

feat: add visibilitychange probe for proactive IDB health check#788

Draft
leshniak wants to merge 10 commits into
Expensify:mainfrom
callstack-internal:fix/idb-visibilitychange-probe
Draft

feat: add visibilitychange probe for proactive IDB health check#788
leshniak wants to merge 10 commits into
Expensify:mainfrom
callstack-internal:fix/idb-visibilitychange-probe

Conversation

@leshniak
Copy link
Copy Markdown
Contributor

Details

Adds a proactive visibilitychange probe to the IDB connection manager, building on the reactive heal mechanism from #780.

Problem: Safari kills IDB connections for backgrounded tabs (WebKit #197050, #201483). When the user returns to the Expensify tab, the app fires a ReconnectApp write storm that hits the dead cached dbp — every write fails before the reactive heal can kick in.

Solution: Register a visibilitychange listener inside createStore() that runs a lightweight readonly count() probe when the tab becomes visible. If the probe detects a dead IDB connection, it drops the stale dbp before the write storm arrives, so the first real operation opens a fresh connection.

What it does:

  • isStaleConnectionError() — union detector for all three stale connection error types (InvalidStateError, backing store corruption, connection lost)
  • visibilitychange listener with probePromise guard — prevents stale probe from clearing a dbp that was already replaced by a concurrent heal/retry
  • Classified req.onerror — only drops dbp for actual stale connection errors, not unrelated IDB errors
  • Guarded by typeof document !== 'undefined' for SSR/Node safety

Depends on: #780 (reactive heal mechanism)

Related Issues

Expensify/App#87864

Automated Tests

4 new tests in tests/unit/storage/providers/createStoreTest.ts:

Visibilitychange probe (4): probe detects dead connection + drops dbp, skipped when no dbp, healthy connection preserved, InvalidStateError sync throw handled

All 456 tests pass.

Manual Test Steps

Simulating Safari connection lost:

  1. Open the app in Safari, log in
  2. Switch to a different tab and wait 30+ seconds (Safari may kill IDB connections for backgrounded tabs)
  3. Switch back to the Expensify tab
  4. Verify in console: IDB visibilitychange probe: connection lost, dropping cached connection appears (if Safari killed the connection)
  5. Interact with the app — it should recover seamlessly

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
  • 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

Screenshots/Videos

Android: Native

N/A — library-level change, IDB is web-only. No UI, no native code touched.

Android: mWeb Chrome

N/A — library-level change, IDB is web-only. No UI, no native code touched.

iOS: Native

N/A — library-level change, IDB is web-only. No UI, no native code touched.

iOS: mWeb Safari

N/A — library-level change, IDB is web-only. No UI, no native code touched.

MacOS: Chrome / Safari

N/A — library-level change, no UI. Verified via 456/456 unit tests passing.

leshniak and others added 7 commits May 19, 2026 16:05
Adds a Dexie-style heal pattern to createStore for Chromium's
Internal error opening backing store error (884K errors/month).

- isBackingStoreError() detects the Chromium-specific corruption
- Shared healAttemptsRemaining counter (3, reset on success)
- On backing store error: clear cached connection, retry once
- Clear dbp on rejection so retries get fresh indexedDB.open()
- 5 new tests: mid-session heal, init heal, budget exhaustion,
  budget reset, error classification

No deleteDatabase(), no provider swap, no UI changes.
Scoped to IDBKeyValProvider only -- SQLite provider untouched.

Ref: Expensify/App#90636

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Capture dbp reference before attaching reject handler; only clear if
  dbp hasn't been replaced by a concurrent heal/retry (prevents stale
  rejection handler from clearing a newer promise)
- Add comment documenting concurrent store() budget drain behavior
- Fix test formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ose)

The heal path clears the cached dbp and reopens via indexedDB.open(),
but does not call db.close() on the old IDBDatabase. Updated comments
and log messages from 'close + reopen' to 'drop cached connection and
reopen' to match what the code actually does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- isBackingStoreError: use Error instead of DOMException, drop .name check
- InvalidStateError catch: same simplification
- Remove issue link from JSDoc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add isConnectionLostError() to detect 'Connection to Indexed Database
server lost' and 'Connection is closing' — Safari/WebKit errors that
fire when the browser terminates IDB connections for backgrounded tabs.

Uses the same heal-and-retry mechanism as backing store corruption:
drop cached dbp, retry once with fresh indexedDB.open(), shared budget.

Addresses Expensify/App#87864.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract module-level helpers: isInvalidStateError, isBudgetedHealError,
getBudgetedHealErrorLabel. Extract cacheOpenPromise to deduplicate
rejected-promise cleanup in getDB and verifyStoreExists.

Pure refactor — no behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Register a visibilitychange listener inside createStore() that runs a
lightweight readonly probe when the tab returns to foreground. If the
probe detects a dead IDB connection (connection lost, backing store
error, or InvalidStateError), it drops the cached dbp so the next real
operation opens a fresh connection instead of failing.

This prevents the ReconnectApp write storm from hitting a dead IDB
connection after Safari backgrounds a tab.

Addresses Expensify/App#87864.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
leshniak and others added 2 commits May 20, 2026 13:54
- Probe start: logInfo when tab becomes visible and probe begins
- Probe healthy: logInfo confirming connection is healthy
- Probe stale: logAlert with error details when stale connection detected
- Heal attempts/success/exhaustion/non-recoverable: same as Expensify#780
- Updated test assertions to match new log messages and levels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "connection is healthy" log was emitted synchronously after
count(), before the IDB request completed. If the request later
failed via onerror, both healthy and stale logs would fire for
the same visibility event. Now only logs on actual success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant