Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.2.0-next.20

- **Reverts the reload-hold behavior added in `0.2.0-next.19`.** That release
withheld the doorbell reload while a client's own writes were in flight, to stop
rows popping out and back in. It measurably reduced the flicker but did not fix
it, so it isn't worth the complexity it carries.

Two reasons it falls short. It bounds how long a ping can be held, so that
continuous editing keeps seeing other people's changes — and past that bound a
reload fires mid-flight and the flicker returns (reproduced at 2.6s under
sustained writes). More fundamentally, it guards when a reload is *issued* but
not when its result is *applied*: a reload already in flight still lands after
the next optimistic row appears and replaces the list without it.

Both are the same underlying thing — any design that replaces the whole list
from a server snapshot has a window in which that snapshot is stale. The fix is
to stop replacing the list (merge the changed row by id), not to keep shrinking
the window.

Preserved on the `preserve/realtime-reload-hold` branch.

## 0.2.0-next.19

- Rows no longer pop out and back in when you add several quickly. The doorbell
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bool-sdk",
"version": "0.2.0-next.19",
"version": "0.2.0-next.20",
"description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).",
"type": "module",
"main": "./dist/index.js",
Expand Down
85 changes: 2 additions & 83 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,6 @@ 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/<label>/… keyed on the request host. Mirrors `deploymentSlugFromHost`
Expand Down Expand Up @@ -274,31 +264,6 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {

// Route REST + Storage through the gateway; leave everything else (the
// realtime WebSocket, in particular) connecting directly to Supabase.
// ── The reload race ────────────────────────────────────────────────────────
// The doorbell says "something changed" without saying WHICH row, so an app
// reloads its whole list on every ping. If that reload is issued while the
// app's own writes are still in flight, the server answers WITHOUT them — and
// the app replaces what's on screen with a snapshot missing rows the user has
// already added. They vanish, then reappear on the next ping. Adding three
// todos quickly is enough to see it.
//
// It's a lost-update race, not latency: even an instantaneous network returns
// a snapshot that legitimately lacks uncommitted rows, so no amount of speed
// fixes it.
//
// The SDK is the one place that sees both sides — it issues every write AND
// owns the broadcast handler. So hold the reload signal while this client's
// writes are landing, then fire once when they drain. App code is untouched:
// the existing `subscribe(() => load())` pattern simply stops flickering.
let pendingWrites = 0;
const drainWaiters = new Set<() => void>();

function noteWriteSettled(): void {
pendingWrites = Math.max(0, pendingWrites - 1);
// Copy before iterating: a waiter may unsubscribe during the callback.
if (pendingWrites === 0) for (const wake of [...drainWaiters]) wake();
}

const proxyFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const raw =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
Expand All @@ -311,19 +276,11 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
// credentials:include so the live-gate identity cookie flows to the
// gateway (same-origin or custom-domain); the viewer token covers the
// cross-origin preview.
const call = fetch(`${GATEWAY}/_bool/${GATEWAY_API}/db${url.pathname}${url.search}`, {
return fetch(`${GATEWAY}/_bool/${GATEWAY_API}/db${url.pathname}${url.search}`, {
...init,
headers,
credentials: "include",
});
// Count writes so doorbell pings can wait for them. Counted HERE rather
// than in the entities layer because both styles we teach —
// `supabase.from(...).insert(...)` and `bool.entities.x.create(...)` —
// funnel through this fetch, and only one of them goes via entities.
const method = (init?.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return call;
pendingWrites++;
return call.finally(noteWriteSettled);
}
return fetch(input as RequestInfo, init);
};
Expand Down Expand Up @@ -641,51 +598,13 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
const subscribeToChanges = (
listener: (payload: BoolChangePayload) => void,
): (() => void) => {
let held: BoolChangePayload | null = null;
let heldSince = 0;
let flushTimer: ReturnType<typeof setTimeout> | null = null;

function flush(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
const payload = held;
held = null;
heldSince = 0;
if (payload) listener(payload);
}

function scheduleFlush(): void {
if (flushTimer) return; // a flush is already pending; it'll take the latest
flushTimer = setTimeout(flush, PING_COALESCE_MS);
}

function onPing(payload: BoolChangePayload): void {
held = payload;
if (!heldSince) heldSince = Date.now();
// Wait for this client's own writes to land — but not past the ceiling, or
// continuous local editing would starve the app of other users' changes.
if (pendingWrites > 0 && Date.now() - heldSince < MAX_HOLD_MS) return;
scheduleFlush();
}

const onDrain = (): void => {
if (held) scheduleFlush();
};
drainWaiters.add(onDrain);

const channel = db
.channel("bool:" + schema)
.on("broadcast", { event: "*" }, (msg) =>
onPing((msg as { payload?: BoolChangePayload }).payload ?? {}),
listener((msg as { payload?: BoolChangePayload }).payload ?? {}),
)
.subscribe();

return () => {
drainWaiters.delete(onDrain);
if (flushTimer) clearTimeout(flushTimer);
held = null;
void db.removeChannel(channel);
};
};
Expand Down
227 changes: 0 additions & 227 deletions src/reload-race.test.ts

This file was deleted.

Loading