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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

## [Unreleased]

Nothing yet.
### Fixed

- **The page socket's redial curve resets on the node's first frame, not on the open.** A proxy or
an overloaded node that accepts the upgrade and drops it before saying a word reset `#attempt`
every time, so the tab redialled every ~0.5 s for as long as that lasted instead of backing off to
`BROWSER_RECONNECT_MAX_MS`. Any frame from the node now proves the socket carries.
- **A tab that comes back online or visible dials at once.** The page socket listens for `online`
and a visible `visibilitychange` and cuts a LOCAL backoff short (`LiveClient.wake()`), so a laptop
waking from sleep is live again before its armed wait runs out. A node-assigned drain slot is kept,
so a deploy's herd stays spread.

## 27.14.1 - 2026-10-22

Expand Down
6 changes: 5 additions & 1 deletion examples/dummy/apps/web/app/feed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,12 @@ export const config = defineRoute({
* page's in-flight writes are answered (`catch-up-hold.ts`, `record-landings.ts`), so a read
* that lands before a write's echo never paints one like too many; and an offline tap reaches
* the outbox on disk before it paints, so a reload at that instant keeps the write.
* raised 162kb → 163kb (#855). measured: 166,122 B (2026-10-22; CI `x build --target static`),
* against 165,888. why: the page boot's socket redials at once on `online` and a visible
* `visibilitychange` (`LiveClient.wake()`), so a tab back from sleep is live before its armed
* backoff runs out, and its curve resets on the node's first frame rather than on the open.
*/
budget: { js: '162kb' },
budget: { js: '163kb' },
/** The badge's count is a read, so it is resolved here — the only place this page fetches. */
load: () => memberQueries.feedActivity({ orgId: useActor().orgId }),
meta: ({ t }) => ({ title: t('app.feed.metaTitle'), robots: { index: false } }),
Expand Down
4 changes: 2 additions & 2 deletions examples/dummy/x.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"manifestVersion": 1,
"buildId": "92080ba34442de8b",
"buildId": "30db2d2ee4f00d67",
"app": {
"name": "@postly/root",
"version": "0.0.1"
Expand Down Expand Up @@ -431,7 +431,7 @@
"revalidateTags": [],
"surface": "app",
"budget": {
"js": "162kb"
"js": "163kb"
}
},
{
Expand Down
13 changes: 11 additions & 2 deletions packages/realtime/src/client-reconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ import type { JsonObject, Row } from './json';
import { PROTOCOL_VERSION } from './sync-protocol';
import { BROWSER_RECONNECT_MAX_MS } from './thundering-herd';

const nodeHello = {
type: 'hello',
v: PROTOCOL_VERSION,
buildId: 'b',
sessionId: 's1',
actorId: null,
} as const;

describe('LiveClient reconnect', () => {
test('a dropped socket arms a timer that actually dials again', () => {
const { client, timers, sockets } = harness();
Expand Down Expand Up @@ -80,9 +88,10 @@ describe('LiveClient reconnect', () => {
expect(timers.delays).toEqual([500, 1000, 2000]);

timers.fire();
sockets[3]?.open(); // this one lands
sockets[3]?.open(); // this one lands, and the node answers on it
sockets[3]?.deliver(nodeHello);
sockets[3]?.disconnect();
expect(timers.delays.at(-1)).toBe(500); // attempt counter reset on open
expect(timers.delays.at(-1)).toBe(500); // attempt counter reset on the node's first frame
});

test('the whole subscription set is re-established on the automatic reconnect', () => {
Expand Down
67 changes: 67 additions & 0 deletions packages/realtime/src/client-wake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// The two ways a redial loses time: a socket that opens and is dropped before the node says a
// word (a proxy, an overloaded node) resetting the backoff, and a tab that came back online or
// visible sitting out a wait armed while it was gone. `wake()` is the second's cure.

import { describe, expect, test } from 'bun:test';
import { harness } from './client-harness-fixture';
import { PROTOCOL_VERSION } from './sync-protocol';

describe('LiveClient backoff reset and wake', () => {
test('a socket that opens and drops before any frame keeps backing off', () => {
const { client, timers, sockets } = harness();
client.connect();
sockets[0]?.open();
sockets[0]?.disconnect();
for (let dial = 1; dial <= 3; dial++) {
timers.fire();
sockets[dial]?.open(); // accepted, then dropped with no word from the node
sockets[dial]?.disconnect();
}
expect(timers.delays).toEqual([500, 1000, 2000, 4000]);
});

test('wake() dials at once instead of sitting out a local backoff, and restarts the curve', () => {
const { client, timers, sockets } = harness();
client.connect();
sockets[0]?.open();
sockets[0]?.disconnect();
timers.fire();
sockets[1]?.disconnect();
expect(timers.pending).toBe(1000);

client.wake();
expect(sockets).toHaveLength(3);
expect(timers.pending).toBeNull();
sockets[2]?.disconnect();
expect(timers.delays.at(-1)).toBe(500);
});

test('wake() keeps a node-assigned drain slot, so a deploy herd stays spread', () => {
const { client, timers, sockets } = harness();
client.connect();
sockets[0]?.open();
sockets[0]?.deliver({
type: 'reconnect',
v: PROTOCOL_VERSION,
afterMs: 7_777,
reason: 'drain',
});

client.wake();
expect(sockets).toHaveLength(1);
expect(timers.pending).toBe(7_777);
});

test('wake() is a no-op while connected, and after close()', () => {
const { client, timers, sockets } = harness();
client.connect();
sockets[0]?.open();
client.wake();
expect(sockets).toHaveLength(1);

client.close();
client.wake();
expect(sockets).toHaveLength(1);
expect(timers.pending).toBeNull();
});
});
26 changes: 25 additions & 1 deletion packages/realtime/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export class LiveClient {
#attempt = 0;
/** The armed reconnect's canceller, and the flag for "one timer in flight, the first wins". */
#reconnectTimer: (() => void) | null = null;
/** The armed wait is a node's drain slot: `wake()` leaves it alone, or the herd re-clusters. */
#armedByNode = false;
/** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
#closed = false;
#connected = false;
Expand Down Expand Up @@ -157,7 +159,6 @@ export class LiveClient {
// and the one handler that had none. A replaced socket opening late would otherwise mark the
// live connection up and replay every subscription onto whatever socket is current.
if (this.#socket !== socket) return;
this.#attempt = 0;
this.#setStatus({ connected: true, reconnectAt: null });
// `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
// frame below, which is the only place resume is decided — sending it here too shipped every
Expand All @@ -174,6 +175,10 @@ export class LiveClient {
// that is still draining bytes would otherwise fold its patches into the live registrations
// a second time, over newer state.
if (this.#socket !== socket) return;
// The curve resets on the node's first word, not on the open: a proxy or an overloaded node
// that accepts the upgrade and drops it at once would otherwise reset it every time, and the
// tab redialled every half second for as long as that lasted.
this.#attempt = 0;
this.#heartbeat.saw(this.#clock.now().getTime());
applyFrame(decode(data), this.#frameTarget);
});
Expand Down Expand Up @@ -227,6 +232,24 @@ export class LiveClient {
this.#offline();
}

/**
* The network or the tab is back (`online`, a tab made visible): dial now instead of sitting out
* the armed backoff. Only a local backoff is cut short — a node's drain slot is how a deploy's
* herd is spread, so it is kept — and only while a reconnect is actually armed.
*/
wake(): void {
if (this.#closed || this.#connected || this.#reconnectTimer === null || this.#armedByNode)
return;
this.#cancelReconnect();
this.#attempt = 0;
try {
this.connect();
} catch (error) {
this.#scheduleReconnect(null);
this.#onError(error);
}
}

/**
* Subscribe to a live query. The window holds ids; the rows are the page store's, so a record
* updated by an HTTP response or another window re-renders here too, with no second copy.
Expand Down Expand Up @@ -411,6 +434,7 @@ export class LiveClient {
// `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
serverDelayMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
this.#attempt += 1;
this.#armedByNode = serverDelayMs !== null;
this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay });
const schedule = this.#options.scheduler ?? timeoutScheduler;
this.#reconnectTimer = schedule(() => {
Expand Down
33 changes: 33 additions & 0 deletions packages/realtime/src/page-socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ describe('pageSocket', () => {
expect(client.connected).toBe(true);
});

// A laptop waking from sleep, or a tab brought back, must not sit out a backoff armed while it
// was gone: `online` and a visible `visibilitychange` dial at once; a hidden one does not.
test('online, and the tab made visible, redial at once; a hidden tab waits', async () => {
const { hosts, openHost } = countedHosts();
resetPageSocket({ openHost });
const doc = Object.assign(new EventTarget(), { visibilityState: 'hidden' });
const had = Reflect.get(globalThis, 'document');
Reflect.set(globalThis, 'document', doc);
try {
browserPage(TARGET);
const client = pageSocket('useConnection');
await microtasks();
const sockets = (): FakeSocket[] => hosts[0]?.sockets ?? [];
sockets()[0]?.open();
sockets()[0]?.close(1006);
expect(client.connected).toBe(false);

doc.dispatchEvent(new Event('visibilitychange'));
expect(sockets()).toHaveLength(1); // still hidden: the armed backoff stands

doc.visibilityState = 'visible';
doc.dispatchEvent(new Event('visibilitychange'));
expect(sockets()).toHaveLength(2);

sockets()[1]?.close(1006);
dispatchEvent(new Event('online'));
expect(sockets()).toHaveLength(3);
} finally {
resetPageSocket();
Reflect.set(globalThis, 'document', had);
}
});

test('a new principal leaves the old host and dials exactly once on a host of its own', async () => {
const { hosts, openHost } = countedHosts();
resetPageSocket({ openHost });
Expand Down
8 changes: 8 additions & 0 deletions packages/realtime/src/page-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,24 @@ export function pageSocket(hook: string): LiveClient {
host.rehost();
client.connect();
};
// The network or the tab came back: dial now, not at the end of a backoff armed while it was gone.
const wake = (): void => {
if (typeof document === 'undefined' || document.visibilityState !== 'hidden') client.wake();
};
const listens = typeof addEventListener === 'function';
if (listens) {
addEventListener('pagehide', bye);
addEventListener('pageshow', restored);
addEventListener('online', wake);
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', wake);
}
teardowns.add(() => {
offRescope();
if (listens) {
removeEventListener('pagehide', bye);
removeEventListener('pageshow', restored);
removeEventListener('online', wake);
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', wake);
}
client.close();
host.bye();
Expand Down
Loading