From 5b8a5b90afed8cfe05e4db7f899f8b5e4b13b617 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Mon, 27 Jul 2026 13:43:19 -0500 Subject: [PATCH 1/2] Realtime: hold the reload while this client's own writes are in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/client.ts | 85 ++++++++++++++- src/reload-race.test.ts | 227 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 src/reload-race.test.ts diff --git a/src/client.ts b/src/client.ts index a6e822b..5d27db9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -30,6 +30,16 @@ const GATEWAY_API = "v1"; // used, and the server never returns a token), so it's never exposed there. const EU_SESSION_KEY = "bool_eu_session_token"; +/** Collapse a burst of doorbell pings into one reload. The trigger fires once per + * changed ROW, so a bulk write produces N pings for what the app should treat as + * a single refresh. */ +const PING_COALESCE_MS = 50; + +/** Never hold a ping longer than this, even with writes still in flight — + * otherwise someone editing continuously would stop seeing other people's + * changes for as long as they kept typing. */ +const MAX_HOLD_MS = 2000; + /** True when `host` is a single-label deployment subdomain of `appHost` (e.g. * "acme.bool.so" under "bool.so") — the exact shape the platform proxy rewrites * to /served/