Remove the client's replay-mode / UpToDateTracker mechanism
Summary
The TypeScript client's replay-mode machinery (UpToDateTracker, ReplayingState, the suppressUpToDate transition) has never engaged against a conforming Electric server. Its only observable effects are a localStorage write per shape on every up-to-date, an extra state in the state machine, and a class of bugs (#4722) that can only be reached through a fetch mock. It should be removed.
Background
#3358 added the mechanism to fix multiple renders on page refresh: cached responses replaying rapidly, each ending in an up-to-date. The design records the last seen electric-cursor per shape in localStorage; on a fresh stream within 60 s it enters ReplayingState and suppresses the first up-to-date whose response cursor matches the recorded one.
Why it can't fire
- The server sets
electric-cursoronly onlive=trueresponses (Electric.Shapes.Api.Response.put_cursor_headers/2), and has done so since #2593, which predates #3358. - The client sends
live=trueonly fromLiveState(shape-stream-state.ts,LiveState.applyUrlParams). ReplayingStateis entered only from a fetching state (Initial/Syncing) — i.e. around a non-live request — and exits toLiveStateon the first up-to-date it sees, whether or not it suppresses.- Every route into a fetching state zeroes
liveCacheBuster(createInitialState,reset,markMustRefetch), and a recorded""cursor is rejected by theif (lastSeenCursor)guard before replay mode is entered.
So the first up-to-date a replaying stream handles never carries a cursor, currentCursor is "", and replayCursor === currentCursor is never true. The tests in up-to-date-tracker.test.ts (both the #3358 originals and the C9 test from #4782) only exercise the branch by putting electric-cursor on non-live responses, which the server never does.
Cost of keeping it
UpToDateTrackeris a module-level singleton that reads and writeslocalStorage(throttled) for every shape on every up-to-date.ReplayingStateand thesuppressUpToDatetransition add a state and a code path that reviewers and tests have to reason about; #4722 was a real bug in that path, found by a mock.- When suppression does fire (mock or misbehaving proxy), the stream's state-derived view (
stream.isUpToDate,lastSyncedAt()) says up-to-date while subscribers andShape.statushaven't seen an up-to-date, and the reconciling fresh up-to-date only arrives with the next live response — for an idle shape, the long-poll timeout.
Recommendation
Remove it: drop UpToDateTracker, ReplayingState, the suppressUpToDate transition, the C9 invariant, and the associated tests. The state machine loses one state and the client stops touching localStorage on every up-to-date. The original multiple-render symptom from #3358 does not appear reproducible on current main — browser-cached live responses would only replay if the client re-issued identical offset/handle/cursor URLs after a refresh, which it doesn't from offset=-1 — and if it resurfaces it should be addressed with an input the server actually emits on the responses in question.
References
- #3358 — original mechanism
- #4722 / #4782 — data-drop bug in the suppression path and its fix, including the reachability analysis in the PR description and the C9 annotation in
packages/typescript-client/SPEC.md
Source: electric-sql/electric