Skip to content

dogfood: rapid edits leave the dev page unstyled (#893 residual gap) #1397

Description

@vivek7405

Every line anchor below was verified against HEAD 4a335549. Numbers marked "measured" were taken at that commit by the scripts described in "Measurements". Re-verify anchors before editing if main has moved.

Problem

Run webjs dev on website/, then edit files the way an agent does (several saves over a few seconds). The page in the browser comes back as unstyled HTML and stays that way until a manual refresh.

Measured on website/ at 4a335549:

  • Every save under app/ / components/ / modules/ / lib/ restarts the process (node --watch, packages/cli/lib/dev-supervisor.js:46-64).
  • Restart to first served byte is 1.9-2.4s, dominated by ensureReady(). Re-measured directly at HEAD through createRequestHandler({ appDir: website }), a cold GET /public/favicon.svg took 1907ms while the server logged analysis warm in 1900ms (graph 194, scan 31, gate 0, actions 4, middleware 0, elision 136, vendor 1535). The same request warm took 2ms.
  • During the restart window the port is closed. curl reports Failed to connect (refused), not a reset.

Why the stylesheet is the visible casualty, specifically:

  1. A failed stylesheet request is non-fatal to the browser, so it stops blocking and paints unstyled rather than erroring. The document request has to succeed for a page to be on screen at all, so the CSS is the follow-up that lands in the next down window.
  2. /public/tailwind.css is the slowest request on the page. It waits on ensureReady() and runs the dev.regenerate Tailwind compile, so it has the widest exposure to the next restart.

Two corrections to the original report (both measured at HEAD)

The burst produces 2N reload signals, not N. Each save fires the in-process reload SSE frame first (the fs.watch rebuild, debounced 80ms at dev.js:1777), and then, once node --watch has torn the process down and the browser's EventSource reconnects to the fresh one, a second signal from the changed boot id in the hello frame. Measured with 8 saves 1500ms apart against a minimal app: 16 signals, 8 reload frames and 8 hello frames with 8 distinct boot ids, gaps alternating 429ms (same save, in-process frame to reconnect) and 1071ms (reconnect to the next save's in-process frame). The first of each pair is the dangerous one, because it is emitted by a process node --watch is already killing.

A sub-restart-time burst already coalesces on its own. With 8 saves 300ms apart, the process never finishes booting, so the whole burst produced only 2 signals (one reload at t=86ms, one hello at t=2638ms, 2552ms apart). So the issue's original "~300ms" quiet-window suggestion would coalesce almost nothing. The window has to be sized against the realistic agent cadence (the 1071ms gap), not the rapid-fire one.

/public/* is handled in handleCore, not in produce(). The original text said produce(). The branch is at dev.js:2113, inside handleCore (dev.js:2064), which produce() reaches through next() at dev.js:1568 after await ensureReady() at dev.js:1567 and after root middleware at dev.js:1569-1577.

Relationship to #893 / #896 (read before starting)

#893 reported this exact symptom and was closed as completed by #896. #896 deliberately changed the design: it kept node --watch's full restart and fixed only the client-side reload protocol (probe-then-reload, reconnect-as-edit-signal). Its own commit message says so, "keeping node --watch's full restart (it is what makes deep transitive-import edits take effect, since the dev ?t= cache-bust only refreshes the entry module)".

Those two fixes protect the document request. Nothing protects the subresources fetched after a gated reload succeeds, which is the case still reproducing here. #893's acceptance criterion 1 ("no blank/CSS-less intermediate paint") is therefore still unmet in practice. Possibly the same underlying restart storm as #968 (closed not-planned, "browser refreshes ~5x/sec").

Design / approach

Two independent fixes, both settled below with no open questions.

1. Coalesce the reload signal, browser-side

One debounced emitter in packages/server/src/dev-reload-worker.js that BOTH reload signals route through (the hello boot-id change and the reload frame). Reload once the signals stop for RELOAD_QUIET_MS = 2000, or at the latest RELOAD_MAX_HOLD_MS = 5000 after the first signal of the current batch.

Why 2000ms for the quiet window. It has to exceed the measured 1071ms inter-save gap of a realistic agent burst, or a burst still yields one reload per save. It must not sit NEAR that gap either: a window of roughly 1000ms would fire the reload almost exactly as the next save's restart begins, which is the worst possible phase. 2000ms is 4.7x the measured 429ms intra-save gap and 1.9x the 1071ms inter-save gap, so both collapse. It is also bounded above by something useful, the 1900ms analysis warm measured on the website app. The wait overlaps warm-up work the reload request would have blocked on anyway (warmup() is fired the moment the fresh process listens, which is also when the hello arrives), so on the app where this was reported the debounce costs no wall clock at all. Going higher would buy only the second reload in the rapid-fire shape (2552ms apart, so still two reloads at 2000ms) and would start to be felt on a small app whose warm is fast.

Why 5000ms for the max-hold cap. A pure quiet window freezes the page for the whole burst, so the cap is not optional. 5000ms is 2.5x the quiet window, so a sustained burst repaints at least every 5 seconds (the measured 12s burst would produce 2 to 3 reloads instead of 16) while still being long enough that the cap never fires for an ordinary pair of edits. It sits at the top of the range the original report suggested (3-5s).

Why the debounce must live browser-side. Under node --watch the server process dies on every edit, so no server-side timer can span a burst. The SharedWorker survives, since it is keyed by script URL, not page lifetime.

Why the per-tab fallback reuses the same relay. reloadClientJs's __webjsDirectEvents (dev.js:3032-3045) is the live path wherever SharedWorker is missing (Chrome for Android has none) or its construction throws (a strict dev CSP with no worker-src). It already duplicates the boot-id rule the worker implements, so leaving it alone would mean the debounce simply does not exist on those browsers, and a second copy of the debounce would be a second thing to drift. So the fallback is rewritten to call startReloadWorker over a shim port. That is one implementation of the boot-id rule, the error cache, and the debounce, and it deletes duplication that exists today.

Alternatives considered and rejected.

  • Leading-edge debounce (fire immediately, suppress the rest). Rejected. The first signal of a burst is emitted by a process that is already being killed, so firing on it reloads into a server about to disappear, which is exactly the reported failure. Trailing only.
  • A quiet window with no cap. Rejected. It freezes the page on stale content for the entire burst, and an agent burst can run for a minute.
  • Making the two numbers configurable through a webjs.dev.* key. Rejected. A new webjs.* key owes the three-surface lockstep (JSON Schema, WebjsConfig type, reader) plus the KNOWN_KEYS drift test, for a dev-loop constant nobody has asked to tune. Constants in the module, exported so tests can assert against them.
  • Dropping node --watch (the original dogfood: Node dev full-restarts on every app edit (downtime + CSS flash) #893 design). Rejected by fix: first-class dev hot-reload (no restart flash, watch outside appDir) #896 for a sound reason and it stays rejected: the full restart is what makes deep transitive-import edits take effect, since the dev ?t= cache-bust only refreshes the entry module.
  • A parent-held TCP relay so the port never closes. Rejected, recorded here so it is not re-derived. It is the only structural guarantee (the webjs parent survives every restart, so it could bind the user-facing port and relay to the child on an ephemeral one, holding connections instead of refusing them). It also adds a proxy hop to every dev request forever, to solve a problem these two fixes make rare. Revisit only if the symptom survives them.

Prior art. Trailing-debounce coalescing of a reload signal is the standard shape. Turbo debounces its page refresh at ~/Documents/Projects/frameworks/turbo/src/core/session.js:43 (#pageRefreshDebouncePeriod = 150) through the trailing-only helper at turbo/src/util.js:275. Vite does the same for a full reload at ~/Documents/Projects/frameworks/vite/packages/vite/src/node/server/bundledDev.ts:72 (debounce(20, ...), helper at line 522) and for re-optimization at vite/packages/vite/src/node/optimizer/optimizer.ts:38 (debounceMs = 100, armed in debouncedProcessing at line 620). Neither carries a max-hold cap, because in both cases the coalesced signal is cheap (an HMR message, a re-bundle) and the burst is short. Here the signal costs a full page reload gated on a process restart and the burst can be a minute long, so the cap is the WebJs-specific addition, not something to copy from either.

2. Serve /public/* before ensureReady(), in dev only

Static assets need neither the module graph nor the vendor importmap, but /public/* is handled at dev.js:2113 inside handleCore, which runs after await ensureReady() at dev.js:1567. tryServeFrameworkStatic already carves /__webjs/core/* out for exactly this reason (#190, dev.js:1550-1564). The same treatment drops the CSS request from the measured 1907ms to the regenerate compile alone, which both shrinks the exposure window and lets a cold restart serve CSS immediately.

Settled: the early serve is gated to dev only. The middleware concern the original text hedged on ("consider") is real, not hypothetical. Root middleware runs at dev.js:1569-1577, before handleCore, so /public/* is behind it today and hoisting moves it in front. The gating decision:

  • The measured problem (a closed port during a node --watch restart) exists only in dev. In prod, /__webjs/ready stays 503 until analysis AND the first vendor attempt have both completed, so a readiness-gated platform never sends a real request to a cold instance. The prod cold-start window is already closed by design.
  • Bypassing root middleware in prod would silently un-gate a /public/* file that an app middleware protects. That is a behaviour change with a security edge and no measured benefit given the point above.
  • The cost is a narrow dev/prod divergence: in dev, root middleware does not run for a /public/* request that hits the early path. This is the same trade the framework statics already make in BOTH modes, and it gets documented rather than left implicit.

Settled: the handleCore branch is not removed and is not dead. Because the hoist is dev-only, that branch is the LIVE path in prod and the fallback in dev, exactly like the second tryServeFrameworkStatic call at dev.js:2089. Both call sites share ONE extracted function, so the traversal guard exists once and cannot drift between them. Duplicating the guard into a second copy would be the actual hazard here.

Cross-issue landmine (#1399)

#1399 is being planned in parallel and will edit packages/server/src/vendor.js plus its call site at packages/server/src/dev.js:1143 (scanBareImports(appDir, ...)). This work must not touch vendor.js at all, and must not touch dev.js:1143. Every edit here is at dev.js:1556-1567, dev.js:1988-2062 (a new function appended after tryServeFrameworkStatic), dev.js:2107-2146, and dev.js:3003-3078, all below line 1143, so no line above it shifts and the two PRs merge cleanly.

Note also that #1399 removes roughly 1.3s of jspm round trip from ensureReady() (measured vendor 1535 of the analysis warm in 1900ms line above). That shrinks the window this issue measures but does not remove the need for either fix: the port is still closed for the whole restart, and the reload storm is unaffected by how fast the analysis is.

Implementation plan

Step 1. Add the debounced emitter to packages/server/src/dev-reload-worker.js

The module is browser-safe with NO node imports, and dev.js inlines it by reading the file, stripping the export keyword, and appending the bootstrap call (dev.js:2975-2980 and dev.js:3073-3078):

const RELOAD_WORKER_SRC = readFileSync(new URL('./dev-reload-worker.js', import.meta.url), 'utf8')
  .replace(/^export /gm, '');
...
function reloadWorkerJs(bp) {
  return `// webjs dev reload worker (one shared connection for all tabs)
${RELOAD_WORKER_SRC}
startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
`;
}

So the module must stay import-free and every new top-level binding must survive ^export stripping into a classic worker script. export const RELOAD_QUIET_MS = 2000; becomes const RELOAD_QUIET_MS = 2000;, which is fine. The browser test at packages/server/test/dev/browser/reload-worker.test.js:13 imports the module directly (import { startReloadWorker } from '../../../src/dev-reload-worker.js'), which is what makes the shipped source and the tested source the same bytes (the #887 / #264 no-drift pattern).

Today, lines 49-54 read:

  es.addEventListener('hello', (e) => {
    if (lastBoot !== null && e.data !== lastBoot) fanout({ type: 'reload' });
    lastBoot = e.data;
  });

  es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });

After the change, add two exported constants above the function, a timer source, and one emitter both listeners route through:

/**
 * Reload coalescing (#1397). An agent saves several files a second or two
 * apart, and EACH save produces TWO reload signals: the in-process `reload`
 * frame from the fs.watch rebuild, then a changed boot id when the browser
 * reconnects to the process `node --watch` restarted (measured 429ms apart, and
 * 1071ms between one save's reconnect and the next save's in-process frame).
 * Acting on every one reloads into a server that is about to be killed again,
 * which is how the page ends up unstyled.
 *
 * 2000ms is above the measured 1071ms inter-save gap so a realistic burst
 * collapses to one reload, and deliberately not NEAR it, since a window close
 * to that gap fires just as the next restart begins. It is also under the
 * 1900ms analysis warm measured on the website app, so on a real app the wait
 * overlaps work the reload request would have blocked on anyway.
 */
export const RELOAD_QUIET_MS = 2000;

/**
 * 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
 * whole agent burst, so a sustained burst still repaints at least this often.
 * 2.5x the quiet window, so it never fires for an ordinary pair of edits.
 */
export const RELOAD_MAX_HOLD_MS = 5000;

export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
  /** @type {Set<any>} */
  const ports = new Set();
  /** @type {string | null} the last error frame, cached for late-joining tabs */
  let lastError = null;
  /** @type {string | null} the last-seen per-process boot id (#893) */
  let lastBoot = null;

  // Timers come from the worker global when it has them (the real SharedWorker
  // scope does, and so does a tab running the per-tab fallback), else the
  // ambient globals. Reading them off `scope` is what lets the browser test
  // drive a fake clock and keep its assertions synchronous, and calling them as
  // `timers.setTimeout(...)` keeps `this` the global in a real browser.
  const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;

  function fanout(msg) {
    for (const p of ports) {
      try { p.postMessage(msg); } catch (_) { ports.delete(p); }
    }
  }

  // The ONE debounced reload emitter (#1397). Two timers rather than a clock:
  // the quiet timer is re-armed by every signal, the cap timer is armed once
  // per batch and NEVER re-armed, so the cap measures from the first signal of
  // the batch instead of sliding with the burst. Whichever fires first emits
  // and cancels the other, which also starts a fresh batch.
  /** @type {any} */ let quietTimer = null;
  /** @type {any} */ let capTimer = null;

  function emitReload() {
    if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
    if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
    fanout({ type: 'reload' });
  }

  function requestReload() {
    if (quietTimer !== null) timers.clearTimeout(quietTimer);
    quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
    if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
  }

and route both listeners through it:

  es.addEventListener('hello', (e) => {
    if (lastBoot !== null && e.data !== lastBoot) requestReload();
    lastBoot = e.data;
  });

  // The cached error is cleared IMMEDIATELY, not on the debounced emit: a tab
  // connecting during the pending window must not be replayed an overlay for an
  // error the rebuild already fixed.
  es.addEventListener('reload', () => { lastError = null; requestReload(); });
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });

webjs-error stays undebounced. An overlay has to appear at once, and it is not a reload.

The existing return { ports, es }; at line 70 is unchanged.

Do not defeat two existing guards. isRegenerateOutputPath (packages/server/src/dev-regenerate.js:225) already stops a regenerate OUTPUT write from triggering a reload at all, upstream of this emitter, and nothing here touches it. Each tab still gates its reload on the /__webjs/version readiness probe in __webjsReloadWhenReady (dev.js:3022-3031, the fetch at dev.js:3025); the debounce delays the message that reaches that gate and must not bypass it, which it does not, since the tab-side handler is unchanged.

Step 2. Make the per-tab fallback reuse the relay, in packages/server/src/dev.js

At dev.js:2968-2980 the client inlines only the overlay source today. Add the worker source to the client too, so the relay function and the two constants are in scope in the tab:

  return `// webjs dev reload client
${DEV_OVERLAY_SRC}
${RELOAD_WORKER_SRC}

Then replace __webjsDirectEvents (dev.js:3032-3045), which today re-implements the boot-id rule:

function __webjsDirectEvents() {
  var lastBoot = null;
  const es = new EventSource(${eventsUrl});
  es.addEventListener('hello', (e) => {
    if (lastBoot !== null && e.data !== lastBoot) __webjsReloadWhenReady();
    lastBoot = e.data;
  });
  es.addEventListener('reload', () => __webjsReloadWhenReady());
  es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
}

with a shim-port call into the SAME relay, so the boot-id rule, the cached error, and the reload debounce are one implementation rather than two that drift (#1397):

function __webjsDirectEvents() {
  // No SharedWorker (Chrome for Android has none) or its construction threw (a
  // strict dev CSP with no worker-src). Run the SAME relay here in the tab over
  // a shim port instead of a second copy of the boot-id rule (#887, #893) and
  // the reload debounce (#1397). \`scope\` has no setTimeout, so the relay picks
  // up the tab's own timers.
  const scope = {};
  startReloadWorker(scope, EventSource, ${eventsUrl});
  scope.onconnect({ ports: [{ start() {}, postMessage(m) {
    if (m.type === 'reload') __webjsReloadWhenReady();
    else if (m.type === 'webjs-error') __webjsApplyError(m.data);
  } }] });
}

The try / catch SharedWorker block at dev.js:3046-3060 is unchanged, and new Function(clientSrc) must still parse (asserted today at packages/server/test/dev/reload-shared-connection.test.js).

Step 3. Extract the /public/* serve into one function, in packages/server/src/dev.js

Add tryServePublicAsset immediately after tryServeFrameworkStatic ends at dev.js:2062. It is the verbatim move of the current branch body at dev.js:2112-2146, traversal guard included, with the return contract made explicit:

/**
 * Serve `/public/*`, the `/sw.js` + `/offline.html` root remaps (#830), and
 * `/favicon.ico`. Extracted (#1397) so the dev pre-`ensureReady()` call site and
 * the `handleCore` one share ONE implementation, and the containment guard below
 * cannot drift between two copies.
 *
 * @param {string} path decoded pathname
 * @param {{ appDir: string, dev: boolean, versioned?: boolean, regenerateRules: any[] }} ctx
 * @returns {Promise<Response|null>} a Response, or null when the path is not a
 *   public asset OR the file does not exist, in both of which cases the caller
 *   must fall through to the rest of the pipeline (a missing `/public/x.png`
 *   404s through normal routing today, and that must not change). A containment
 *   rejection returns a 404 Response and short-circuits.
 */
async function tryServePublicAsset(path, ctx) {
  const { appDir, dev, versioned, regenerateRules } = ctx;
  const ROOT_ASSETS = { '/sw.js': '/public/sw.js', '/offline.html': '/public/offline.html' };
  if (!(path.startsWith('/public/') || path === '/favicon.ico' || path in ROOT_ASSETS)) return null;
  const p = path === '/favicon.ico' ? '/public/favicon.ico' : (ROOT_ASSETS[path] || path);
  const abs = join(appDir, p);
  // Containment check. `join` normalises `..` segments, so a path like
  // `/public/%2E%2E/secret/x.svg` decodes to `/public/../secret/x.svg` and
  // resolves to `appDir/secret/x.svg`, inside appDir but OUTSIDE appDir/public/.
  const publicRoot = join(appDir, 'public') + sep;
  if (!abs.startsWith(publicRoot)) {
    return new Response(null, { status: 404 });
  }
  if (dev && regenerateRules.length) {
    await maybeRegenerate(appDir, p.replace(/^\/+/, ''), regenerateRules);
  }
  if (await exists(abs)) {
    const res = await fileResponse(abs, { dev, immutable: versioned });
    if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
    return res;
  }
  return null;
}

Keep the full existing comments from dev.js:2107-2144 on the moved code (the #830 root-asset rationale, the traversal-guard paragraph, the #967 regenerate paragraph, and the ?v= immutable note). They are condensed above only to keep this plan readable.

Then replace the branch at dev.js:2107-2146 with the call:

  // Static: /public/*, the #830 root remaps, and /favicon.ico. In dev this
  // already ran before ensureReady() (#1397); this stays the LIVE path in prod
  // and the fallback in dev, and both call sites share one implementation.
  const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules });
  if (publicResp) return publicResp;

Step 4. Call it before ensureReady(), dev only

At dev.js:1556-1567 the decoded path and the ?v= flag are already computed for the framework-static call:

      let assetPath = probePath;
      try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
      let assetVersioned = false;
      try { assetVersioned = new URL(req.url).searchParams.has('v'); } catch { /* none */ }
      const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: assetVersioned });
      if (staticResp) return staticResp;
      // Build all whole-app analysis on the first request (memoized), before
      // any SSR, module serve, gate check, action dispatch, or middleware runs.
      await ensureReady();

Insert the public serve between them:

      const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: assetVersioned });
      if (staticResp) return staticResp;
      // `/public/*` needs neither the module graph nor the vendor importmap, so
      // in DEV serve it here rather than behind the analysis (#1397). A cold
      // `/public/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
      // `ensureReady()`, which is why the stylesheet is the request most exposed
      // to the next `node --watch` restart. DEV ONLY on purpose: in prod
      // `/__webjs/ready` already holds traffic off a cold instance until the
      // analysis and the first vendor attempt have both completed, and hoisting
      // there would silently un-gate a `/public/*` file that an app middleware
      // protects. The consequence in dev is that root middleware does not run
      // for these paths, the same trade the framework statics above already make
      // in both modes. `state.regenerateRules` is loaded at boot (dev.js:848),
      // so the #967 on-request rebuild is available here, ahead of ensureReady.
      if (dev) {
        const publicResp = await tryServePublicAsset(assetPath, {
          appDir, dev, versioned: assetVersioned, regenerateRules: state.regenerateRules,
        });
        if (publicResp) return publicResp;
      }
      await ensureReady();

state.regenerateRules is populated at dev.js:848 inside createRequestHandler, before handle can be called, and refreshed on each rebuild at dev.js:1247. So it is always available here and no deferral is needed. This is inside produce(), so the response still goes through the handle() funnel (security headers, the CSP / request-id steps, applyConditionalGet, the access log) exactly as before.

Tests

Every layer below is required. Run them and report the result; npm test does not run browser, e2e, or Bun.

Browser (required, the headline behaviour)

Extend packages/server/test/dev/browser/reload-worker.test.js, following its existing harness exactly (the FakeEventSource class and fakePort() helper at lines 17-26, assert from ../../../../../test/browser-assert.js, and importing the real module so the tested bytes are the shipped bytes). Add a fake clock, since the module now reads setTimeout / clearTimeout off scope:

function fakeClock() {
  let now = 0, id = 0;
  const jobs = new Map();
  return {
    scope: {
      setTimeout(fn, ms) { jobs.set(++id, { at: now + ms, fn }); return id; },
      clearTimeout(t) { jobs.delete(t); },
    },
    tick(ms) {
      now += ms;
      for (const [t, j] of [...jobs].sort((a, b) => a[1].at - b[1].at)) {
        if (j.at <= now) { jobs.delete(t); j.fn(); }
      }
    },
  };
}

The seven existing cases must be updated to drive that scope and tick(RELOAD_QUIET_MS) where they assert a reload. New cases, importing RELOAD_QUIET_MS / RELOAD_MAX_HOLD_MS rather than hardcoding numbers:

  1. a burst of signals inside the quiet window fans exactly ONE reload. Fire reload, hello BOOT_B, reload, hello BOOT_C with tick values summing under the window between each, then tick(RELOAD_QUIET_MS). Assert the port received exactly one { type: 'reload' }. Counterfactual: revert the emitter and this sees four.
  2. a single signal in a quiet session reloads after exactly the quiet window. tick(RELOAD_QUIET_MS - 1) asserts nothing yet, tick(1) asserts one reload. Pins "not delayed past the quiet window" in both directions.
  3. a sustained burst still reloads at the cap, measured from the first signal. Fire a signal every RELOAD_QUIET_MS - 100 until past the cap. Assert the reload lands at RELOAD_MAX_HOLD_MS from the FIRST signal, not later. Counterfactual: re-arming the cap timer on each signal (the bug this guards) pushes it out forever and the case never sees a reload.
  4. the cap timer is not re-armed within a batch. A second signal arriving mid-batch must not extend the cap.
  5. a signal after a cap fire starts a NEW batch. Assert it waits a full quiet window rather than firing immediately.
  6. an error frame is never debounced. webjs-error reaches the port with no tick.
  7. a reload signal clears the cached error immediately, before the debounced emit. Fire reload, connect a late tab with NO tick, assert it is replayed nothing. Guards the ordering in step 1.

Server (node)

New file packages/server/test/dev/public-before-analysis.test.js, named after its siblings (root-assets.test.js, dev-regenerate-serve.test.js, asset-helper-serve.test.js) and built on the same mkdtempSync fixture plus createRequestHandler shape.

Make the analysis deterministically slow with no network by giving the fixture a root middleware.ts whose module body has a top-level await new Promise(r => setTimeout(r, 500)), since loadMiddleware runs inside ensureReady(). Then assert ORDER, not wall clock:

const order = [];
const publicP = app.handle(new Request('http://x/public/a.css')).then((r) => { order.push('public'); return r; });
const warmP = app.warmup().then(() => order.push('warm'));
await Promise.all([publicP, warmP]);
assert.deepEqual(order, ['public', 'warm']);

Cases:

  1. The ordering assertion above, plus status 200 and the body bytes. Counterfactual: revert step 4 and the order becomes ['warm', 'public'].
  2. Root middleware does NOT run for /public/* in dev, and DOES in prod (dev: false). Pins the settled dev-only gating in both directions.
  3. The traversal guard still 404s on the early path (/public/%2E%2E/secret.txt with a real secret.txt beside public/). Counterfactual: dropping the guard from the extracted function serves the file, which is a directory-traversal hole.
  4. A missing /public/nope.png still falls through to normal routing (404 from the router, not a short-circuit), proving the return null contract.
  5. /sw.js and /offline.html still serve at the root through the early path, with Service-Worker-Allowed: / on /sw.js (the dogfood: scaffold serves public at /public/* so the documented /sw.js service worker 404s #830 behaviour, already covered in root-assets.test.js, re-asserted here because the code moved).
  6. A stale webjs.dev.regenerate output is rebuilt before serving on the early path (dogfood: hybrid Tailwind in dev so local CSS never goes stale (follow-up to #947) #967 still holds ahead of ensureReady).
  7. ?v= still yields immutable, un-versioned still yields the 1h fallback.

Also update packages/server/test/dev/reload-shared-connection.test.js. Three of its client-source assertions describe the __webjsDirectEvents implementation that step 2 replaces and will go red as written:

  • /addEventListener\('reload', \(\) => __webjsReloadWhenReady\(\)\)/
  • /if \(lastBoot !== null && e\.data !== lastBoot\) __webjsReloadWhenReady\(\)/
  • /addEventListener\('hello'/

Replace them with assertions on the new contract (the client inlines function startReloadWorker, the fallback calls it with a shim port, both message types still route to __webjsReloadWhenReady / __webjsApplyError, and new Function(clientSrc) still parses). Add one assertion that the served worker source carries the debounce (const RELOAD_QUIET_MS) and that no export keyword survives into either classic script.

Bun parity (required)

It applies, for two independent reasons. Substantively, the /public/* hoist moves a serve branch earlier in the request path, and the two listener shells reach handle() differently (the Bun shell may rebuild the Request in forwardedRequest and classifies the response body through readBufferedOrStream / compressBufferSync), so the invariant is worth proving on both. Mechanically, .claude/hooks/require-bun-parity-with-runtime-src.sh matches /dev\.js in its runtime-sensitive regex and BLOCKS a commit staging it with no test/bun/** file.

The worker source itself is emitted identically by both shells, so the debounce needs no Bun coverage of its own. Note that bun --hot never restarts the process, so the changed-boot-id path does not fire there; the coverage below deliberately targets the serve path, which does exist on both.

New pair test/bun/dev-public-before-warm.mjs plus test/bun/dev-public-before-warm.test.mjs, following test/bun/dev-reload-retry.mjs exactly (spawn packages/cli/bin/webjs.js dev --port <9500 + pid % 240> against an mkdtempSync app with @webjsdev/core and @webjsdev/server symlinked in, detached, killed by process group in a finally). Give the fixture a slow root middleware.ts (top-level await sleep, 1500ms) and a public/a.css. Poll until the port accepts at all, then assert in one pass that GET /public/a.css is 200 while GET /__webjs/ready is still 503. Counterfactual: revert step 4 and the CSS request cannot resolve until warm completes, so /__webjs/ready is already 200 when it does and the assertion fails.

Run node scripts/run-bun-tests.js and bun test/bun/dev-public-before-warm.mjs, and report both.

Layers that do NOT apply

  • e2e (test/e2e/*.test.mjs). No e2e harness drives a node --watch restart cycle, and building one to observe a 2 to 5 second debounce would add a multi-second real-time wait for behaviour the browser layer already asserts deterministically against the shipped module. The serve half is fully covered at the server and Bun layers.
  • smoke (test/examples/*/smoke/*). No scaffold, example app, or generated output changes.

Docs

  • packages/server/AGENTS.md, the dev.js row of the module map. Extend the "Graceful reload (dogfood: Node dev full-restarts on every app edit (downtime + CSS flash) #893)" sentences with the coalescing (both signals route through one debounced emitter, the two constants and what each is for, and that the per-tab fallback now runs the same relay over a shim port rather than a second copy of the boot-id rule).
  • packages/server/AGENTS.md, package invariant 3, the paragraph beginning "Framework-internal static assets are also served before ensureReady". Add that in DEV /public/*, the /sw.js + /offline.html root remaps, and /favicon.ico are served there too, that this is dev-only and why, and that the consequence is that root middleware does not run for those paths in dev.
  • website/app/docs/middleware/page.ts, line 11, which currently claims root middleware "runs on every request". That is already inaccurate for /__webjs/* framework statics and this change widens it. State the exceptions: the framework's own /__webjs/* assets and probes in both modes, and /public/* in dev.
  • framework-dev.md, the dev-loop section that today describes the error overlay over the live-reload SSE channel. One sentence that a reload is coalesced, so after a burst the page reloads once the edits settle (up to 5s during a sustained burst), which is the first thing to check when a reload looks "missing".

Root AGENTS.md needs no change: no public API, no webjs.* config key, no CLI flag, and no invariant moves. website/app/docs/deployment/page.ts line 19 was checked and left alone, since it describes the dev/prod split at a level the debounce does not change. .agents/skills/webjs/references/built-ins.md was checked (its dev.watch / dev.regenerate copy) and needs no change, since neither key's behaviour moves. The WEBJS_NO_DOC_GATE=1 escape hatch is NOT needed here, because real doc surfaces change.

Acceptance criteria

  • A burst of rapid edits produces ONE reload after the burst settles, not two per saved file
  • A sustained burst still reloads at the max-hold cap, measured from the first signal of the batch, so the page never freezes on stale content
  • A single edit in a quiet session reloads after exactly the quiet window, never later and never not at all
  • The debounce is one emitter that both the hello boot-id path and the reload frame route through, and webjs-error is not debounced
  • The per-tab (no SharedWorker) fallback gets the identical behaviour by running the same relay, with no second copy of the boot-id rule
  • Every reload still passes through the /__webjs/version readiness gate, and isRegenerateOutputPath still suppresses regenerate-output writes
  • In dev, /public/* is served without waiting for ensureReady(), proven by an ordering assertion, and a cold restart serves CSS immediately
  • In prod, /public/* behaviour is byte-for-byte unchanged, root middleware included
  • The traversal guard travels with the moved code and exists in exactly one place
  • A missing /public/* file still falls through to normal routing
  • webjs.dev.regenerate still rebuilds a stale output before serving it on the early path
  • Every new test has a stated counterfactual that fails when the change is reverted
  • Bun matrix run and reported green, including the new test/bun/dev-public-before-warm.mjs under bun
  • bun --hot and webjs dev --no-hot behaviour unchanged
  • The diff touches neither packages/server/src/vendor.js nor packages/server/src/dev.js:1143 (dogfood: vendor scan walks scripts/, sends devDependencies to jspm #1399 merges cleanly)
  • Docs updated on all four surfaces named above

Out of scope

Do not widen into any of these.

  • The parent-held TCP relay so the dev port never closes. Rejected above with reasons; revisit only if the symptom survives both fixes.
  • Dropping node --watch or changing the restart model. fix: first-class dev hot-reload (no restart flash, watch outside appDir) #896 settled this.
  • Anything in packages/server/src/vendor.js, and the scanBareImports call site at packages/server/src/dev.js:1143. That is dogfood: vendor scan walks scripts/, sends devDependencies to jspm #1399, in flight in parallel.
  • Making the quiet window or the cap configurable through a webjs.* key. That owes the schema plus type plus reader lockstep and the KNOWN_KEYS drift test for a dev-loop constant nobody has asked to tune.
  • The 80ms fs.watch rebuild debounce at dev.js:1777. It is server-side and dies with the process on every restart, so it is not the coalescing point.
  • isRegenerateOutputPath (dev-regenerate.js:225) and the regenerate machinery generally. The early serve calls maybeRegenerate with the same arguments as today and nothing else about dogfood: hybrid Tailwind in dev so local CSS never goes stale (follow-up to #947) #967 moves.
  • Any prod behaviour change, including hoisting /public/* in prod.
  • Filing follow-up issues for anything found along the way. Fold a small tweak in a file this PR already touches into this PR, and report anything genuinely separate rather than opening an issue for it.

Measurements (all at 4a335549, scripts kept out of the repo)

  1. Cold /public/* latency. createRequestHandler({ appDir: 'website', dev: false }), then a first GET /public/favicon.svg. Result: 1907ms cold, 2ms warm, with analysis warm in 1900ms (graph 194, scan 31, gate 0, actions 4, middleware 0, elision 136, vendor 1535). dev: false was used so the measurement wrote nothing into the repo; the blocking structure is identical in both modes, since the branch sits in handleCore either way.
  2. Reload signal cadence. A minimal app in a temp dir with @webjsdev/* symlinked in, webjs dev on a non-default port, one SSE consumer reconnecting after 300ms (matching the server's own retry: 300 hint), counting reload and hello frames while saving app/page.ts on a fixed interval. 8 saves 1500ms apart produced 16 signals (8 reload, 8 hello, 8 distinct boot ids) with gaps alternating 429ms and 1071ms. 8 saves 300ms apart produced 2 signals 2552ms apart. Last save to last signal was ~520ms in both runs.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions