Realtime: stop rows popping out and back in - #16
Merged
Merged
Conversation
Fixes rows popping out and back in when you add several quickly. Measured on a live app: a row the user had already added was absent from the screen for 200ms before reappearing. It is a lost-update race, not latency. The doorbell says "something changed" without saying which row, so an app reloads its whole list on every ping. A reload issued while the app's own inserts are still in flight comes back WITHOUT them, and replaces what is on screen with a snapshot missing rows the user has already added. An instantaneous network would not help: the snapshot legitimately lacks uncommitted rows, so speed cannot fix it. The SDK is the one place that sees both sides of the race — it issues every write and it owns the broadcast handler. So it now withholds the reload signal while this client's writes are landing and fires once when they drain, plus a short trailing coalesce so a burst collapses into one reload (the trigger fires per changed ROW, so a bulk write produces N pings for one logical refresh). A ceiling bounds the hold, otherwise continuous local editing would starve the app of other users' changes. Chosen over the alternatives because it needs no new API, no prompt rewrite, and no server change — which means it fixes apps that ALREADY EXIST, on their next publish. Every other approach (a row id in the payload, a merge-by-id helper, a live-list store) requires new app code, so it only helps newly generated apps. Those remain worth doing for the wasted-bandwidth problem, which this does not address: a ping still triggers a full table reload, just never a racing one. Writes are counted in proxyFetch rather than in the entities layer because both styles we teach — supabase.from(...).insert(...) and entities.x.create(...) — funnel through that fetch, and only one of them goes through entities. A test covers the raw-supabase path for exactly that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cuts the release carrying the reload-race fix. Version 0.2.0-next.18 was bumped in the repo but never published (npm's `next` tag is still at next.17), so releasing next.19 ships both it and this change. The version string is what routes the publish: because it contains a hyphen, .github/workflows/publish.yml derives dist-tag `next` from it, so this lands on the canary channel only — invisible to `latest` and to caret ranges, so no production app can pull it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Add several todos quickly and some vanish for a moment, then come back. Measured on a live app — a row the user had already added was absent from the screen for 200ms:
It's a lost-update race, not latency. The doorbell says "something changed" without saying which row, so the app reloads its whole list on every ping. A reload issued while the app's own inserts are still in flight comes back without them, and replaces what's on screen with a snapshot missing rows the user already added.
An instantaneous network wouldn't help — the snapshot legitimately lacks uncommitted rows. Speed cannot fix this.
The fix
The SDK is the one place that sees both sides of the race: it issues every write and owns the broadcast handler. So it withholds the reload signal while this client's writes are landing, and fires once when they drain.
Your three-add case now resolves as: three writes in flight → every ping held → last write settles → one reload, which returns
[A,B,C]and matches what's already on screen. Nothing moves.Plus a short trailing coalesce, since the trigger fires per changed row — a bulk write produces N pings for one logical refresh. And a ceiling on the hold, so continuous local editing can't starve the app of other users' changes.
Why this approach
Three ways to fix this were on the table. This one was chosen because it's the only one that fixes apps that already exist.
No new API, no prompt rewrite, no server change, no version gating. Existing apps running
subscribe(() => load())simply stop flickering when they next publish.What it does NOT fix
A ping still triggers a full table reload — just never a racing one. The wasted bandwidth is untouched, and that's what the id-in-payload and live-store work address. Those stay worth doing; they're tracked separately and are additive to this.
Implementation note
Writes are counted in
proxyFetch, not in the entities layer, because both styles we teach —supabase.from(...).insert(...)andbool.entities.x.create(...)— funnel through that fetch and only one goes via entities. There's a test for the raw-supabase path specifically.Tests
9 new tests that make the race deterministic: writes park until the test releases them, so "in flight" is decided rather than timed. Covers the three-rapid-writes case, burst coalescing, a failed write not leaking the counter, reads never holding anything back, unsubscribe cancelling a pending reload, and the raw-supabase write path.
117 pass, typecheck clean.
🤖 Generated with Claude Code