Skip to content

perf(tabs): serialize the tab tree once per debounce window - #1174

Merged
h4yfans merged 1 commit into
mainfrom
tab-persistence-debounce-serialize
Aug 11, 2026
Merged

perf(tabs): serialize the tab tree once per debounce window#1174
h4yfans merged 1 commit into
mainfrom
tab-persistence-debounce-serialize

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

useTabPersistence debounced only the write. The effect body itself ran
serializeTabState(state) and JSON.stringify(serialized) synchronously on
every tab-state change, before the debounce ever applied. A burst of N state
changes performed N full walks of the tab tree and N JSON encodes to produce
exactly one save.

The beforeunload listener was also torn down and re-added on every state change
([state, enabled] deps), because the handler closed over state.

Corrected trigger. The issue guessed "per keystroke title update"; no
per-keystroke title dispatch exists. The real high-frequency driver is
split-pane.tsxonResizeRESIZE_SPLIT, dispatched on every
mousemove
while dragging a split divider (no rAF throttle). Every discrete
tab action (open/close/activate/pin/reorder/rename, SAVE_TAB_STATE view-state
writes) paid the same cost once each.

Measured cost of one serialize + stringify (30 tabs, each with a realistic
viewState blob, 18,571-byte payload, 2000 iterations after warm-up):
0.0320 ms/op. At pointer-event frequency that is ~2 MB/s of throwaway strings
and object graphs on the renderer thread, for a single write.

Root cause

apps/desktop/src/renderer/src/contexts/tabs/persistence/hooks.ts — the
serialize + stringify pair lived in the effect body instead of in the
setTimeout callback.

Fix

Move serializeTabState + JSON.stringify (and the lastSavedRef dedupe that
depends on them) inside the debounced callback. The effect still re-runs on every
state change and still clears/reschedules the timer, so only the last timer of
a burst survives and it closes over the newest state
— the payload written is
identical to before.

Register the beforeunload listener once ([enabled] deps) and read
stateRef.current, the ref the flush registry already keeps in sync via a
no-dep effect declared above it (so it is committed before any later effect or
any async browser event can read it).

Measured before / after

serializations for mount + 20 state changes beforeunload registrations
before 21 21
after 0 until the debounce fires, then 1 1

Test evidence

New tests in persistence.test.tsx count real calls into serializeTabState
(module mocked with importOriginal passthrough, so the counter wraps the real
implementation) — assertions are on observable counts, never on timing.

RED, before the fix:

 × ... > serializes once per debounce window, not once per tab-state change
   → expected 21 to be +0 // Object.is equality
 × ... > registers the unload handler once and writes the latest state on unload
   → expected [ [ 'beforeunload', …(1) ], …(20) ] to have a length of 1 but got 21

GREEN, after the fix: Tests 7 passed (7).

Both tests pin the exact persisted payload (every tab field, layout,
settings, and savedAt under a pinned system clock), not just its shape.

Mutation verification — 4 mutants, 4 killed

# one-line mutation result
M1 hoist const serialized = serializeTabState(state) back out of the setTimeout REDexpected 21 to be +0
M2 }, [enabled])}, [state, enabled]) on the unload effect REDexpected … to have a length of 1 but got 21
M3 serializeTabState(stateRef.current)serializeTabState(state) in the unload handler RED — persisted "title": "Roadmap" instead of "Roadmap 20"
M4 drop state from the debounce effect deps (a cheaper "schedule once, never reschedule" implementation: serialize count stays 1) RED — persisted "title": "Roadmap" instead of "Roadmap 20"

M4 is the important one: it proves the exact-payload assertion, not just the
count assertion, is load-bearing, so a cheaper implementation that serializes
once but writes stale state cannot pass.

Risk & backward compatibility

Persisted payload — unchanged. serializeTabState is untouched; the same
PersistedTabState (version: 2) is written to the same memry_tab_state
localStorage key with the same fields. A tree written by an older app version
still restores here, and a tree written here still restores in an older version.
Nothing about what is written changed — only where in the timeline the
identical computation happens.

The final pending write is never dropped:

  • App quit (Cmd+Q): main sends app:request-flushuseFlushOnQuit
    flushAllPendingSaves() → the tab-state registry entry serializes
    stateRef.current and saveSyncs it synchronously to localStorage. The
    pending debounced write is superseded by a full write of the newest state.
    Untouched by this PR, and it already read through the ref.
  • Window close: same app:request-flush handshake (per-window, per-request
    after fix(shutdown): scope the window flush handshake per window and request #1150), same registry callback, same synchronous write.
  • beforeunload: still writes synchronously via saveSync. It now reads
    stateRef.current instead of a closed-over state — equally fresh (the ref is
    synced by a no-dep effect declared above it, so it is current at commit time).
  • Vault switch: switchVault does not reload or unmount the renderer;
    TabPersistenceManager stays mounted and the pending timer keeps running and
    fires normally. Tab state is a single global key, not per-vault. Unchanged
    before/after.

No memoization, no cached serialized string. The dedupe key (lastSavedRef)
is computed fresh from the live state each time the timer fires — there is no
cache that could go stale, so there is no invalidation matrix to get wrong. The
lastSavedRef comparison keeps exactly its previous semantics (it is effectively
inert either way because serializeTabState stamps savedAt: Date.now();
deliberately left alone as out of scope).

Behavioural delta, stated precisely: previously, when the new state
serialized identically to the last saved one, the effect returned early and left
an older pending timer alive; now the timer is always rescheduled and the write
is skipped inside the callback. Either way the newest state is what ends up
persisted.

Verification

  • pnpm typecheck — 16/16 tasks successful
  • pnpm lint — 0 errors, 15 pre-existing warnings, none in the changed files
    (confirmed with a targeted eslint --no-cache --max-warnings=0 on hooks.ts:
    clean)
  • pnpm exec vitest run --config config/vitest.config.ts --project renderer src/renderer/src/contexts/tabs src/renderer/src/App.test.tsx src/renderer/src/components/split-view136 passed, 0 failed
  • git diff --check — clean
  • npx -y react-doctor@latest . — run twice, byte-identical output (1855 files,
    score 64/100), no findings in the changed files

Docs

pnpm docs:impact --base origin/main --strict reports missing-docs because the
diff touches apps/desktop/**. I reviewed the docs and deliberately did not
update them
: this is an internal timing change with no user-visible effect.
user-guide/tabs-split-view.md and user-guide/settings.md describe what
Restore Session brings back; the payload, the debounce interval, and the restore
semantics are all unchanged, so those pages remain accurate.

Relation to other open work

Out-of-scope findings (recorded, not fixed)

  1. split-pane.tsx:76 dispatches RESIZE_SPLIT on every mousemove with no
    rAF/throttle, so a divider drag pushes 60–120 reducer passes per second
    through the whole tab tree. This PR removes the serialization cost from that
    path, but the reducer churn and the resulting re-render of every tab pane
    remain.
  2. serializeTabState stamps savedAt: Date.now(), so the lastSavedRef dedupe
    in useTabPersistence can never match and is dead in practice — every
    debounce window writes, even when nothing durable changed.
  3. SET_TAB_MODIFIED / UPDATE_TAB_TITLE in tab-modify-reducer.ts allocate a
    fresh state object even when the value is unchanged, so idempotent calls still
    trigger a full re-render of every tab-state consumer.

Closes #1056

The auto-save effect ran serializeTabState() plus JSON.stringify() on
every tab-state change and only debounced the write, so a burst of
changes walked the whole tab tree and JSON-encoded the payload once per
change while producing a single save. Dragging a split divider dispatches
RESIZE_SPLIT on every mousemove, so this ran at pointer-event frequency.

Move both calls inside the debounced callback. The effect still re-runs
on every state change, so only the last timer of a burst survives and it
closes over the newest state - the persisted payload is unchanged.

Also register the beforeunload listener once instead of tearing it down
and re-adding it on every state change; it now reads the current state
from the ref the flush registry already keeps in sync.
@github-actions github-actions Bot added the test label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 12ee4cb.

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@h4yfans
h4yfans marked this pull request as ready for review August 8, 2026 01:44
@h4yfans
h4yfans merged commit 9d34e0e into main Aug 11, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[HIGH][tabs] Tab persistence serializes + JSON.stringifies the whole tab tree on every state change (debounce only covers the write)

1 participant