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
2 changes: 1 addition & 1 deletion framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ In development, three error sources push a structured error frame to the open ta

**A render frame is scoped to the URL that produced it (#1047).** A `render` frame carries that url, and the browser half is the single gate deciding whether a frame belongs on the page currently being viewed, so an overlay comes down when the client router navigates away and never goes up for someone else's page. Three consequences worth knowing. A speculative link PREFETCH of a throwing page reports no frame at all, so hovering a link cannot break the page you are looking at (the reported symptom: the frame fans out to every open tab over the shared SSE channel, with no navigation anywhere). A render error in one tab raises nothing in a tab viewing a different page. And a successful render of a url supersedes a retained error for that SAME url, so the replay cannot hand a recovered error to a freshly-connected tab; a good render of an unrelated page deliberately leaves it standing. `ts-strip` and `rebuild` frames carry NO url and are never scoped, because they describe a still-broken build rather than one page, so navigation leaves them alone and only the next successful rebuild clears them. The gate is order-independent: the SSE frame is pushed during the render, before the navigation response is even sent, so a frame for the page being navigated TO is held and rendered once the URL advances. A held frame renders only for the navigation it belongs to, so one that arrived while the tab sat idle is dropped rather than painted on a later visit, when the page may well render fine. An idle-time frame comes from a render this tab did not navigate for (another tab's page, a background fetch of some other url), never from a link prefetch, which reports nothing at all. Mechanism: `renderDevOverlay` + `syncDevOverlayToLocation` + `installDevOverlayNavSync` in `packages/server/src/dev-overlay.js`, wired by the dev reload client to the client router's `webjs:navigate` and `popstate` (a navigation finished) plus `webjs:before-cache` (a navigation STARTED, since the router snapshots the page it is leaving first). That last one also detaches the overlay across the snapshot read and re-attaches it a microtask later, so the cached HTML never carries a copy the module does not own; what it must not do is strip the overlay for good, which would tear a `rebuild` overlay off the page on any link click. `packages/core` is untouched by any of it.

**A reload is coalesced (#1397), so after a burst of edits the page reloads once the edits settle** rather than once per saved file. Each save produces two reload signals (the in-process rebuild frame, then a changed boot id when the browser reconnects to the process the dev supervisor restarted), and acting on every one reloads into a server about to be killed again, which is what leaves the page unstyled. The relay holds the reload until the signals stop for 2 seconds, or at most 5 seconds into a sustained burst. So if a reload looks "missing" right after you saved, wait two seconds before looking for a bug: that is the first thing to check. An error overlay is never held, since it is not a reload.
**A reload is coalesced (#1397), so after a burst of edits the page reloads once the edits settle** rather than once per saved file. Each save produces two reload signals (the in-process rebuild frame, then a changed boot id when the browser reconnects to the process the dev supervisor restarted), and acting on every one reloads into a server about to be killed again, which is what leaves the page unstyled. The relay holds the reload until the signals stop for 2 seconds, or at most 5 seconds into a sustained burst. A server that applies edits in place (Bun's one long-lived dev server, or `--no-hot`) marks its frames `inPlace`, and a batch made only of those waits just 300ms (`RELOAD_QUIET_IN_PLACE_MS`, #1575), since no restart follows; a batch holding any restart signal keeps the 2 second window. So if a reload looks "missing" right after you saved on Node, wait two seconds before looking for a bug: that is the first thing to check. An error overlay is never held, since it is not a reload.

**The stream is quiet, and only open while a tab is visible (#1507).** A host that sleeps on network quiet (a sandbox that suspends after an idle window, a laptop) counts an in-flight request and bytes on the wire as activity, and the reload stream used to supply both forever: a `: ka` comment every 25 seconds per tab, for every tab open, visible or not. Now `SseHub` writes nothing between events, and each listener shell switches its idle timeout off for that one request (`server.timeout(req, 0)` on Bun, `setTimeout(0)` on node) rather than keeping it busy. The relay in `dev-reload-worker.js` holds its `EventSource` open only while some tab reports itself visible (`visibilitychange`, `pagehide`, a bfcache `pageshow`), closes it when the last one hides, and reopens it when one shows. Nothing is lost meanwhile: `hello` carries `{ boot, seq }`, `seq` counting the reload frames the process has sent, so a reconnect that sees a different boot id or seq reloads. A dropped stream is retried by the relay with backoff (300ms doubling to 30s, reset by the next `hello`), never by the browser's fixed retry, and not at all while every tab is hidden. **An open request is activity too, so quiet is not always enough.** Measured on a pilots sandbox: a machine with a 20 second idle window stayed `running` for minutes with one silent, open SSE request in flight, and suspended about a minute after a plain request with nothing open. A host like that counts the in-flight request for its whole life. For it, `webjs.dev.reloadIdle` (seconds, or `WEBJS_DEV_RELOAD_IDLE`, off by default) makes the relay close the stream after that long with no edit and no interaction in any visible tab; the next interaction (tabs report pointer, key, wheel, touch and focus at most once a second), a tab showing, or an embed-bridge host command reopens it, and the hello seq reloads the page if an edit landed meanwhile. It is opt-in because local editing with the browser untouched is exactly the case live reload exists for. **A frame no stream carried is recovered from the page itself (#1516).** A host that holds the quiet stream across a suspend closes it at the next wake, and the reload the wake caused is written to that dead stream; a fresh relay (a new SharedWorker, or the per-tab fallback) has no previous hello to compare with; a tab that connects after a fanout never got it. So in dev every page renders `<meta name="webjs-dev-reload" content='{"boot","seq"}'>` from the `SseHub` (through `setDevReloadState` in `dev-reload-state.js`, set by `startServer`; an embedded `createRequestHandler` has no stream and renders none), the client sends it to the relay as `{ type: 'page' }`, and the relay reloads any tab whose page differs from the server's state on that message and on every hello, targeting only the stale tabs. A tab it has reloaded is recorded as current. Nothing else in `webjs dev` issues a request on a timer: the `/__webjs/version` readiness probe runs only after a reload signal, and prefetch is driven by hover, focus and viewport, not a clock. Tests: `test/dev/browser/reload-worker.test.js`, `test/listener/listener-core.test.js`, `test/e2e/dev-idle-stream.test.mjs`.

Expand Down
2 changes: 1 addition & 1 deletion packages/server/AGENTS.md

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions packages/server/src/dev-reload-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@
*/
export const RELOAD_QUIET_MS = 2000;

/**
* The quiet window for a batch made only of in-process frames from a server
* that reloads IN PLACE (#1575): Bun's one long-lived dev server, or any
* `--no-hot` server. No restart follows such a frame, so nothing is worth
* waiting 2000ms for, and that wait was most of the save-to-paint time (3.8s
* measured in a hosted preview). 300ms still folds a save's few frames and a
* quick burst into one reload. A batch holding any restart signal (a changed
* boot id, a stale page, a frame from a restarting server) keeps the full
* `RELOAD_QUIET_MS`, since the restarted process is still warming.
*/
export const RELOAD_QUIET_IN_PLACE_MS = 300;

/**
* The longest a reload is ever held, measured from the FIRST signal of a batch
* (#1397). A quiet window alone would freeze the page on stale content for a
Expand Down Expand Up @@ -116,6 +128,19 @@ export function pageIsStale(page, boot, seq) {
}

/** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
/**
* Whether a `reload` frame comes from a server that reloads in place (#1575).
* Absent or unreadable means it may restart, the safe (slower) reading.
* @param {string} data
* @returns {boolean}
*/
export function frameInPlace(data) {
try {
const o = JSON.parse(data);
return !!(o && o.inPlace === true);
} catch (_) { return false; }
}

function reloadSeq(data) {
try {
const o = JSON.parse(data);
Expand Down Expand Up @@ -190,6 +215,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
let batchAll = false;
/** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
const batchPorts = new Set();
/** Whether the batch holds a signal from a server that restarts (#1575). */
let batchRestart = false;

function emitReload() {
if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
Expand All @@ -200,6 +227,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
batchAll = false;
batchPorts.clear();
batchRestart = false;
for (const p of targets) {
// The tab applies this reload, so from here its page reflects the state
// the relay knows now; a later hello compares against that.
Expand All @@ -219,11 +247,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
* @param {string} verdict
* @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
*/
function requestReload(verdict, port) {
function requestReload(verdict, port, inPlace) {
if (port === undefined) batchAll = true; else batchPorts.add(port);
if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
// One restart signal anywhere in the batch keeps the long window (#1575).
if (!inPlace) batchRestart = true;
if (quietTimer !== null) timers.clearTimeout(quietTimer);
quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
quietTimer = timers.setTimeout(emitReload, batchRestart ? RELOAD_QUIET_MS : RELOAD_QUIET_IN_PLACE_MS);
if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
}

Expand Down Expand Up @@ -278,7 +308,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
lastError = null;
const n = reloadSeq(e.data);
if (n !== null) lastSeq = n;
requestReload(parseVerdict(e.data));
requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data));
});
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
// The stream dropped (a restarting server, a network blip, a host that
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/dev/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ export async function startServer(opts) {
// shells via listener-core.js, so live-reload + the dev error overlay behave
// identically on both). Built before the handler so its onReload / onDevError
// callbacks can fan out through it.
const hub = new SseHub();
// A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
// embedder), applies an edit in place; a supervised Node child is restarted.
const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
// Pages render the stream's state into a meta tag (#1516), so the relay can
// re-check a page against the server on every reconnect.
if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/listener-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,13 @@ export function loadWsModule(file, dev) {
* tab was hidden can tell, on reconnecting, that it missed an edit.
*/
export class SseHub {
constructor() {
/**
* @param {{ inPlace?: boolean }} [opts] `inPlace`: this server applies an
* edit without restarting its process (#1575), so its reload frames say so
* and the browser relay need not wait for a restart to settle.
*/
constructor({ inPlace = false } = {}) {
this.inPlace = inPlace;
/** @type {Set<{ send: (s: string) => void, close: () => void }>} */
this.clients = new Set();
/** Reload frames sent by this process (#1507). */
Expand Down Expand Up @@ -399,7 +405,7 @@ export class SseHub {
reload(verdict) {
const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
this.seq++;
this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`);
this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq, ...(this.inPlace ? { inPlace: true } : {}) })}\n\n`);
}

/** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
Expand Down
43 changes: 42 additions & 1 deletion packages/server/test/dev/browser/reload-worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* runs in a real browser. The relay is driven with a fake EventSource + fake
* MessagePorts so it needs no live SSE server.
*/
import { startReloadWorker, RELOAD_QUIET_MS, RELOAD_MAX_HOLD_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, parseVerdict, parseHello, pageIsStale } from '../../../src/dev-reload-worker.js';
import { startReloadWorker, RELOAD_QUIET_IN_PLACE_MS, frameInPlace, RELOAD_QUIET_MS, RELOAD_MAX_HOLD_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, parseVerdict, parseHello, pageIsStale } from '../../../src/dev-reload-worker.js';

import { assert } from '../../../../../test/browser-assert.js';

Expand Down Expand Up @@ -673,3 +673,44 @@ suite('dev reload stream pauses when hidden and reconnects with backoff (#1507)'
assert.deepEqual(parseHello('b1'), { boot: 'b1', seq: null });
});
});

// #1575: a server that reloads in place (Bun's one long-lived dev server)
// marks its frames, and nothing restarts behind them, so the relay need not
// sit out the 2000ms restart window that was most of the save-to-paint time.
suite('dev reload settle for an in-place server (#1575)', () => {
test('a batch of in-place frames reloads after the short window', () => {
const { scope, tick } = fakeClock();
startReloadWorker(scope, FakeEventSource, '/__webjs/events');
const a = fakePort();
scope.onconnect({ ports: [a.port] });
FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 1, inPlace: true }));
FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 2, inPlace: true }));
tick(RELOAD_QUIET_IN_PLACE_MS - 1);
assert.deepEqual(a.received, [], 'still folding the burst');
tick(1);
assert.deepEqual(a.received, [{ type: 'reload', verdict: 'page' }], 'one reload, right after the burst');
});

test('a restart signal in the batch keeps the full window', () => {
const { scope, tick } = fakeClock();
startReloadWorker(scope, FakeEventSource, '/__webjs/events');
const a = fakePort();
scope.onconnect({ ports: [a.port] });
FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 1 }));
FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 2, inPlace: true }));
tick(RELOAD_QUIET_IN_PLACE_MS);
assert.deepEqual(a.received, [], 'a frame from a restarting server waits');
tick(RELOAD_QUIET_MS);
assert.deepEqual(a.received, [{ type: 'reload', verdict: 'page' }]);
// The next batch starts short again.
FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 3, inPlace: true }));
tick(RELOAD_QUIET_IN_PLACE_MS);
assert.equal(a.received.length, 2);
});

test('frameInPlace reads only an explicit true', () => {
assert.equal(frameInPlace('{"v":"page","inPlace":true}'), true);
assert.equal(frameInPlace('{"v":"page"}'), false);
assert.equal(frameInPlace('not json'), false);
});
});
7 changes: 7 additions & 0 deletions packages/server/test/dev/hot-reload-robustness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,10 @@ test('devImport never re-imports a specifier that failed, and recovers when the
rmSync(dir, { recursive: true, force: true });
}
});

test('an in-place server marks its reload frames; a restarting one does not (#1575)', async () => {
const { SseHub } = await import('../../src/listener-core.js');
const frames = (hub) => { const out = []; hub.add({ send: (s) => out.push(s), close() {} }); hub.reload({ v: 'page' }); return out[0]; };
assert.match(frames(new SseHub({ inPlace: true })), /"inPlace":true/);
assert.doesNotMatch(frames(new SseHub()), /inPlace/);
});
Loading