Skip to content

Morph page/layout edits in place in dev instead of a full reload #1398

Description

@vivek7405

Line anchors in this body were re-verified against HEAD 43464d87 ("fix: stop rapid dev edits leaving the page unstyled (#1400)"). Every anchor in the previous revision of this body predated #1400 and #1401, and the corrections are noted inline.

Problem

Every dev edit produces a full page reload: the page blinks, hydrated component state resets, scroll position is lost.

For the common case, an edit to a page, a layout, or a server-only module, that is heavier than necessary. Pages and layouts never hydrate (their function runs only on the server), so freshly rendered server HTML is the complete truth for them. Nothing in the browser can be stale after a re-render, which means the page could be updated in place instead of reloaded.

Where the reload actually happens now (anchors corrected)

The previous body cited packages/server/src/dev.js:3026. That is stale. At HEAD the reload lives inside __webjsReloadWhenReady() at packages/server/src/dev.js:3010-3019, and it is already gated on a /__webjs/version probe loop with a 100-try bound:

function __webjsReloadWhenReady() {
  var tries = 0;
  function attempt() {
    fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
      if (r && r.ok) location.reload(); else again();
    }).catch(again);
  }
  function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
  attempt();
}

It has exactly two call sites, both in the same served script: the __webjsDirectEvents per-tab shim port at dev.js:3046, and the SharedWorker port.onmessage at dev.js:3058. Both read the same { type: 'reload' } message the relay fans out.

Other corrected anchors:

Previous body said Actual at 43464d87
dev.js:3026 (location.reload()) dev.js:3010-3019 (__webjsReloadWhenReady), called at :3046 and :3058
dev.js:1783 / :1774-1790 (event.filename) dev.js:1806-1816; `const filename = event.filename
shouldIgnoreWatchPath at L1691 dev.js:1715 (definition), used at :1808
isRegenerateOutput at L1790 dev.js:1684 (the handler-exposed thunk), used at :1814; the predicate itself is isRegenerateOutputPath in dev-regenerate.js:225
dev-reload-worker.js:49-52 (the hello frame) dev-reload-worker.js:114-117
router-client.js:3705-3723 (the x-webjs-src tier) router-client.js:3703-3735

These four are unchanged and correct: router-client.js:666 (navigate), :740 (revalidate), :808 (the onClick same-page-hash guard), :2285 (the same guard inside eligibleAnchorHref).

The reach is narrower than the title suggests. Read this before sizing the work.

The changed filename only exists in the in-process fs.watch path, at dev.js:1807, and it is discarded one line later at dev.js:1815, where a debounced app.rebuild() is called with no arguments:

const rebuild = debounce(() => app.rebuild(), 80);
...
const filename = event.filename || '';
if (shouldIgnoreWatchPath(filename)) continue;
if (app.isRegenerateOutput(filename)) continue;
rebuild();

The second reload signal has no filename at all and never can. webjs dev re-execs under a supervisor (packages/cli/lib/dev-supervisor.js:39-65), and on Node that supervisor is node --watch with these watch paths:

for (const dir of ['app', 'components', 'modules', 'lib', 'actions']) {
  if (exists(dir)) watchPaths.push('--watch-path', dir);
}

So on Node, an edit under app/, components/, modules/, lib/ or actions/ kills the process. The fresh process holds no record of what changed, and the browser learns of the edit only through the changed boot id on the SSE hello frame (dev-reload-worker.js:114-117, the #893 rule), which carries a uuid and nothing else. Confirming the previous body: falling back to a full reload there is correct, and it is also the only option, because nothing survives the restart to classify.

That yields a precise reach, and it is the single most important fact for whoever implements this:

dev mode process survives an edit? can morph?
webjs dev on Bun (bun --hot) yes, Bun invalidates modules in place yes, this is the headline case
webjs dev --no-hot (either runtime) yes, planDevSupervisor returns { mode: 'inline' } yes
webjs dev on Node (node --watch), edit under one of the five watch dirs no, the process is replaced no, always a full reload
webjs dev on Node, edit outside those dirs (db/schema.server.ts, a webjs.dev.watch content dir) yes yes

The Node default path is therefore unchanged by this issue for the majority of edits. That is not a defect to design around: narrowing --watch-path would break deep-import edits, because the dev re-import only appends ?t= to the route module itself and a transitively imported ./util.ts stays cached (see the dev-supervisor.js module docblock). See Out of scope.

Measured behaviour, not assumed

Measured at HEAD against a staged three-file app (root layout, a page importing a @click component) booted with startServer({ appDir, port: 0, dev: true }) in-process, with a raw node:http reader on /__webjs/events:

--- after connect ---
21ms   "retry: 300\nevent: hello\ndata: 31e8dafb-...\n\n"
--- after PAGE edit (t=0 at write) ---
88ms   "event: reload\ndata: now\n\n"          served MARKER_B: true
--- after COMPONENT edit ---
85ms   "event: reload\ndata: now\n\n"

Three things this pins down. The in-process frame arrives about 85ms after the write (the 80ms debounce plus the rebuild). A page edit and a component edit are byte-identical on the wire today, so the classification has to be added to the frame. And no second hello arrives, confirming that the in-process path is a single-signal path.

Two more measured facts that shape the design, both from the same fixture:

  • The boot script is emitted in <head> (index 1589, with </head> at 1733 and <body> at 1741), so it sits outside every <!--wj:children:...--> range. A boundary-range swap can therefore never re-execute it by range reactivation alone.
  • The page renders as <!--wj:children:/:/--> ... <!--/wj:children:/-->. The root layout's own markup is outside that range, which is what makes a layout edit a different problem from a page edit (see Design).

What already exists, and what does not

Already exists:

  • The client router morphs in place on an unchanged route key, and a same-URL navigation is not short-circuited unless there is a hash. Both hash guards (router-client.js:808, :2285) live in onClick / eligibleAnchorHref, never in navigate() or performNavigation, so a programmatic same-URL navigation with no hash runs the full pipeline.
  • navigate() (router-client.js:666) and revalidate() (:740) are public API.
  • addNewHeadElements(doc.head) already runs on both boundary swap tiers (router-client.js:3749, :3804), so a soft swap already updates <title> and already appends a changed boot <script type="module">, which executes and registers a newly added component.
  • The elision pass already computes everything needed to classify (dev.js:965-971, component-elision.js).

Does not exist, and is the actual work:

  • The dogfood: SSR-only deploy not reflected on client nav (build id misses it) #899 x-webjs-src tier (router-client.js:3703-3735) evicts caches so the next navigation is fresh. It never re-renders the page currently on screen. Nothing in the codebase proactively refreshes an on-screen page.
  • Neither public entry can express a same-URL refresh. navigate(url) pushes a duplicate history entry (router-client.js:3043) and scrolls to top (:3053-3069). navigate(url, { replace: true }) is worse: navigate passes opts.replace into performNavigation's isPopState parameter (router-client.js:674), so it takes the snapshot-restore branch and, on a miss, scrolls to top at :1974.
  • The SSE reload frame carries no payload (listener-core.js:369).

Design / approach

Classify the changed file on the server, put a ternary verdict on the SSE frame, and let the browser pick the lightest correct response. Anything the server cannot classify, and every signal that lost its filename, is a full reload.

verdict what changed client response
page a page module, or anything reachable only from pages / server-only files, and nothing that ships to the browser same-URL soft refresh, boundary morph, scroll and outer-layout component state preserved
shell a layout module, or a root convention file whose output lives outside every children range same-URL soft refresh, full-body swap, scroll preserved, component state resets
reload a component module, anything transitively reaching one, an unknown path, or a signal with no filename location.reload(), exactly today's behaviour

Component edits must stay a full reload. customElements.define is once-per-tag at the browser level, so a morph would apply new markup wired to the old class. Prototype-patching (what some Lit HMR plugins do) breaks on changed fields, constructors, and reactive property declarations, and the half-updated states it produces are exactly why "Vite-grade HMR with state preservation" sits on the deliberately-deferred list in the root AGENTS.md. Do not attempt it here. This issue is a cheaper, narrower thing than HMR: re-render on the server and swap, no module hot-swapping.

Why page and shell are two verdicts and not one

Measured above: the root layout's own markup sits outside the <!--wj:children:/:/--> range. The router's two-tier swap morphs the deepest shared boundary when the route key is unchanged (router-client.js:3793-3828), and a same-URL refresh always has an unchanged route key. So a boundary morph updates the page content and can never update the layout's own header, nav, or footer. Collapsing shell into page would make a layout edit silently do nothing, which is strictly worse than the reload it replaced.

shell therefore reuses the already existing full-body tier at router-client.js:3858-3877: mergeHead(doc.head), regraftPermanentElements, document.body.replaceChildren(...), reactivateScripts, upgradeCustomElements. That tier is reachable today only from the background snapshot-restore paths, so the work is to hoist it into a named function and give the refresh path a second call site. It is not a new subsystem, and it is the same shape Turbo uses for a refresh visit (~/Documents/Projects/frameworks/turbo/src/core/drive/page_view.js:18-22 selects MorphingPageRenderer for a same-URL action: "replace").

Why the classification must use the module graph, not the path shape

A path-shape heuristic (app/**/page.ts means morph) is wrong in both directions, and the failure is not exotic:

Vite settles the same question the same way. ~/Documents/Projects/frameworks/vite/packages/vite/src/node/server/hmr.ts:482 resolves the changed file through the module graph (environment.moduleGraph.getModulesByFile(file)) and never inspects the path, then walks importers transitively in propagateUpdate (hmr.ts:799-885), where a single dead end propagates upward as return true and aborts the whole update into a full-reload payload (hmr.ts:747-767). Three specifics are worth copying and one is worth refusing:

  • Copy the pessimistic seed: let needFullReload = modules.length === 0 (hmr.ts:685). An empty or unresolvable result is a reload.
  • Copy the reason string. Vite's HasDeadEnd = string | boolean (hmr.ts:671) lets it print why it reloaded. A bare boolean is undebuggable; carry the reason on the frame.
  • Copy the server-side decision. Vite's client is a dumb executor (packages/vite/src/client/client.ts:292-312).
  • Do not copy Vite's silent return for a file that matched no module (hmr.ts:623-646 logs and does nothing). Vite owns its whole graph; WebJs derives the graph lazily and re-derives it after each rebuild, so an unmatched file is genuinely ambiguous here and must reload.

Next.js makes the same call from a different substrate and confirms the shape: it hashes each module per webpack layer and asks whether the server-layer hash moved (~/Documents/Projects/frameworks/next.js/packages/next/src/server/dev/hot-reloader-webpack.ts:1336-1343, :1377-1393), which is a graph property and not a filename test, then sends a semantic message HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES (hot-reloader-types.ts:23-51) rather than an instruction. Its client keeps a veto and downgrades to window.location.reload() when the page is already in a runtime-error state (packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx:408-439). WebJs gets that veto for free: the router already hard-navigates on a poisoned or disjoint boundary scan (router-client.js:3840-3846), and a dev overlay is already on screen for a broken build.

Alternatives considered and rejected

Reject: classify by path shape. Wrong in both directions, as above. Costs nothing to do properly, since the graph is already built.

Reject: recompute the classification in the restarted process from file mtimes. This is the only way to reach Node's default webjs dev path, and it is a heuristic that misreads a git checkout, an editor save-all, or a formatter run, and it would have to run before the analysis is warm. A wrong morph is a broken page; a wrong reload is a flash. Not worth it.

Reject: treat a boot-id change as "no verdict" so a batch containing one classified page edit can still morph. This is the tempting optimisation and it is unsafe. A burst can contain a page edit whose in-process frame was delivered and a component edit whose frame was killed by the restart 80ms later, leaving the boot-id change as that component edit's only trace. Treating it as no-verdict morphs and leaves the old component class running. A boot-id change is a reload verdict, unconditionally.

Reject: emit the SSE frame from the watcher eagerly, before the debounce and the rebuild, to win the race against node --watch. It narrows the race without closing it, so the boot-id rule above has to stay regardless, and it re-times a surface #1397 and #1400 just tuned. Nothing is bought.

Reject: a bespoke morph subsystem in the reload client. The router already owns boundary scanning, integrity degradation, permanent-element regrafting, seed ingestion, slot resync, and view transitions. A second implementation would drift.

The wire format

SseHub.reload() (listener-core.js:369) emits a bare frame today:

reload() { this._raw('event: reload\ndata: now\n\n'); }

It becomes reload(verdict) emitting a JSON data: payload, matching the devError sibling one line below at :372, which already JSON-encodes its frame. The parse contract is stated once and enforced on the client:

  • The payload is a single-line JSON object {"v":"page"|"shell"|"reload","by":"<app-relative path>","why":"<short reason>"}. by and why are diagnostics only.
  • The client parses e.data with JSON.parse inside a try. Any failure, an absent payload, a non-object, or a v outside the three literals, resolves to reload. The legacy data: now therefore resolves to reload, which is exactly today's behaviour, so there is no version skew hazard between a running browser tab and a restarted server.
  • The relay ignores e.data on reload today (dev-reload-worker.js:123), so nothing currently depends on the old value.

WebJs has no users yet, so data: now is simply replaced rather than kept alongside a new field.

The strongest-verdict rule (the critical interaction with #1397)

#1397 landed as commit 43464d87 via PR #1400 and is closed. Its debounce is the code this builds on, not a dependency to wait for. The previous body's "Depends on #1397" section is removed.

The relay coalesces a burst into one emitted reload (dev-reload-worker.js:88-98), so a batch mixing a page edit and a component edit must collapse to the strongest verdict in the batch, never the last one. Order the three as reload > shell > page and accumulate on every signal, resetting only when a batch is emitted. Both consumers of the relay get the verdict from the same message, because the shim port in dev.js:3028-3051 and the real SharedWorker port in dev.js:3053-3061 are two call sites of one fanout({ type: 'reload' }).

Turbo's refresh guards are worth copying here too (~/Documents/Projects/frameworks/turbo/src/core/session.js:108-117): it drops a refresh that arrives while a navigation is already in flight, and de-dupes a refresh echoing its own request. The router's existing nav token (router-client.js:1810) already supersedes an in-flight navigation, so a refresh that lands mid-nav is aborted rather than interleaved, which is the same outcome.

Implementation plan

packages/ stays plain .js with JSDoc. Do not add a .ts file anywhere under packages/.

Step 1. New pure classifier, packages/server/src/dev-classify.js

New file, sibling to dev-regenerate.js, so the decision is unit-testable without booting a server. One exported function plus one exported constant:

/** The three verdicts, strongest first. Index in this array IS the strength order. */
export const RELOAD_VERDICTS = ['reload', 'shell', 'page'];

/**
 * Classify one changed absolute path into a reload verdict (#1398).
 *
 * @param {string} abs                 absolute path of the changed file
 * @param {object} ctx
 * @param {string} ctx.appDir
 * @param {Set<string>} ctx.shippedFiles   transitive closure of every module the browser can load
 * @param {Set<string>} ctx.pageFiles      every `page.{js,ts}` on the route table
 * @param {boolean} ctx.analysisReady      false before the first `ensureReady()` completes
 * @returns {{ v: 'page'|'shell'|'reload', by: string, why: string }}
 */
export function classifyChangedPath(abs, ctx) { /* ... */ }

The ladder, in order, first match wins:

  1. !ctx.analysisReady returns reload with why: 'analysis-cold'. Nothing is known yet, so fail safe (Vite's pessimistic seed, hmr.ts:685).
  2. abs is not under ctx.appDir returns shell with why: 'extra-watch-root'. The watcher only fires for appDir plus the webjs.dev.watch roots (dev.js:1823-1828), and a file outside appDir is content the server reads at render time, never a browser module.
  3. ctx.shippedFiles.has(abs) returns reload with why: 'ships-to-browser'. This is the transitive answer and the one that matters.
  4. ctx.pageFiles.has(abs) returns page.
  5. abs is in the module graph (the caller passes membership as part of shippedFiles's companion set, see Step 2) returns shell with why: 'server-only-module'.
  6. Anything else returns reload with why: 'unknown-path'. This covers a brand-new file, a deleted file, and every public/ asset. A public/ stylesheet edit must land here: mergeHead preserves stylesheets unconditionally (router-client.js:3860-3862, the dogfood: CSS drops on client-router soft nav on real Android Chrome (styled on refresh) #936 rule), so a swap would visibly do nothing.

by is always the app-relative path, or the absolute path when relativizing fails.

Note what step 3 sweeps in deliberately. A 'use server' action file imported by a shipping component is inside shippedFiles (the graph keeps a .server.* node and stops at it, module-graph.js:363-380), so editing it reloads. That is correct rather than merely conservative: adding an export to that file changes the generated RPC stub the browser holds. The same action imported only by a page falls to step 5 and gets shell.

Step 2. Expose the classifier from the handler, packages/server/src/dev.js

2a. Retain the two derived sets during analysis. Inside ensureReady(), immediately after the elision block at dev.js:965-971 writes state.elidableComponents / state.inertRouteModules / state.importOnlyRouteModules, add:

// #1398: the modules the browser can actually load, so a dev watch event can be
// classified as a browser-affecting change (reload) or a server-only one (morph).
const shippedEntries = [...state.browserEntryFiles].filter((f) =>
  !state.elidableComponents.has(f) &&
  !state.inertRouteModules.has(f) &&
  !state.importOnlyRouteModules.has(f));
state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
state.pageFiles = new Set((state.routeTable.pages || []).map((p) => p.file).filter(Boolean));

reachableFromEntries is already imported in dev.js (used at :955 to build state.browserBoundFiles) and its signature is reachableFromEntries(graph, entryFiles, appDir) (module-graph.js:363). An import-only page's substituted components are already members of browserEntryFiles, so filtering the page out loses nothing. The always-shipped boundaries (error / loading / not-found / forbidden / unauthorized / the two root-only ones / instrumentation-client) are never in inertRouteModules (collectRouteModules at dev.js:2879-2887 feeds only pages and layouts to the analysis), so they stay in shippedEntries and an edit to one correctly reloads.

For step 5 of the ladder, pass graph membership too. seenFilesFor(graph) is already used for the #899 app-source signal in the same function, so pass that Set as ctx.graphFiles and make step 5 read ctx.graphFiles.has(abs).

2b. Reset the sets in doRebuild. doRebuild (dev.js:1217-1263) already sets analysisDone = false. No extra reset is needed, because the classifier is gated on analysisDone, which is what ctx.analysisReady is fed from. Do not make doRebuild await ensureReady(): that would push the SSE frame roughly 1900ms later on a large app (the measured analysis warm cited in dev-reload-worker.js:29-35) and stack that latency in front of the 2000ms quiet window.

The consequence, stated so the implementer does not go looking for a bug: the verdict is computed against the previous build's graph. The one case that could exploit is an edit that adds a brand-new component import to an otherwise morphable page. That is closed on the client rather than the server: addNewHeadElements(doc.head) runs on both boundary tiers (router-client.js:3749, :3804) and its append loop (:4810-4830) removes the stale boot <script type="module"> and appends the incoming one, which executes and registers the new component. Verified by measurement that the boot script is in <head>, so it is addNewHeadElements and not range reactivation that carries it.

2c. Expose the thunk. Beside isRegenerateOutput on the returned handler (dev.js:1680-1684), add:

/** Classify a dev-watcher filename into a reload verdict (#1398). `startServer`'s
 * watcher is a different scope with no access to `state`, exactly like
 * `isRegenerateOutput` above. Returns the fail-safe `reload` verdict until the
 * first analysis has completed. */
classifyWatchPath: (filename) => classifyChangedPath(resolve(appDir, filename), {
  appDir,
  shippedFiles: state.shippedFiles || new Set(),
  graphFiles: state.graphFiles || new Set(),
  pageFiles: state.pageFiles || new Set(),
  analysisReady: analysisDone,
}),

2d. Accumulate the verdict across the watch debounce. In startServer's watcher (dev.js:1801-1816), the filename is currently dropped. Today:

const rebuild = debounce(() => app.rebuild(), 80);
...
const filename = event.filename || '';
if (shouldIgnoreWatchPath(filename)) continue;
if (app.isRegenerateOutput(filename)) continue;
rebuild();

After:

// #1398: several files can change inside one 80ms debounce window, so hold the
// STRONGEST verdict of the window and hand it to the rebuild. Same rule as the
// relay's cross-batch accumulation, for the same reason.
let pendingVerdict = null;
const rebuild = debounce(() => { const v = pendingVerdict; pendingVerdict = null; app.rebuild(v); }, 80);
...
const filename = event.filename || '';
if (shouldIgnoreWatchPath(filename)) continue;
if (app.isRegenerateOutput(filename)) continue;
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename));
rebuild();

strongerVerdict(a, b) is exported from dev-classify.js and compares by index into RELOAD_VERDICTS, treating null as weakest.

2e. Thread the verdict to onReload. rebuild(verdict) at dev.js:1207-1215 forwards to doRebuild(verdict), whose last line at dev.js:1262 becomes:

opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });

Every other rebuild() caller (there are no others in dev.js, and an embedded host may call app.rebuild()) passes nothing and therefore reloads, which is the fail-safe default. Update the onReload JSDoc at dev.js:533.

Step 3. Carry the verdict on the SSE frame, packages/server/src/listener-core.js

SseHub.reload() at :369 gains a parameter and switches to a JSON payload, matching devError on the next line:

/**
 * Push a live-reload event to every open tab (#1398). `verdict` is the
 * `{ v, by, why }` classification of the change; an absent one is emitted as
 * `reload`, and the client resolves any unparseable payload to `reload` too, so
 * the fail-safe holds on both ends of the wire.
 * @param {{ v: string, by?: string, why?: string } | undefined} [verdict]
 */
reload(verdict) {
  const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
  this._raw(`event: reload\ndata: ${JSON.stringify(v)}\n\n`);
}

Update the call site at dev.js:1778 to onReload: (verdict) => hub.reload(verdict).

Step 4. Parse and accumulate in the relay, packages/server/src/dev-reload-worker.js

The relay is the browser half and both consumers run it, so this is the one place the rule lives. Today (:88-98, :114-117, :123):

function emitReload() {
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
  fanout({ type: 'reload' });
}
function requestReload() { ... }
es.addEventListener('hello', (e) => { if (lastBoot !== null && e.data !== lastBoot) requestReload(); lastBoot = e.data; });
es.addEventListener('reload', () => { lastError = null; requestReload(); });

After:

/**
 * Verdict strength, strongest first (#1398). A batch collapses to ONE emitted
 * reload, so it must take the STRONGEST verdict in the batch and never the last
 * one: a burst mixing a page edit and a component edit is a component edit.
 */
export const VERDICT_STRENGTH = ['reload', 'shell', 'page'];

/**
 * Resolve an SSE `reload` frame's `data` to a verdict. ANY failure resolves to
 * `reload`: an absent payload, a non-object, a legacy `data: now`, or a `v`
 * outside the three literals. That is what makes the wire-format change safe in
 * both directions between a running tab and a restarted server.
 */
export function parseVerdict(data) {
  try {
    const o = JSON.parse(data);
    if (o && typeof o === 'object' && VERDICT_STRENGTH.indexOf(o.v) > 0) return o.v;
  } catch (_) { /* fall through */ }
  return 'reload';
}

/** @type {string} the strongest verdict seen in the current batch */
let batchVerdict = 'page';

function emitReload() {
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
  const v = batchVerdict;
  batchVerdict = 'page';          // a fresh batch starts at the weakest verdict
  fanout({ type: 'reload', verdict: v });
}

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

es.addEventListener('hello', (e) => {
  // A changed boot id means the process was replaced (#893), and a restart
  // carries NO filename, so it is unconditionally a full reload (#1398). It is
  // NOT a "no verdict" signal: a burst can hold a page edit whose frame was
  // delivered and a component edit whose frame died with the old process, and
  // the boot-id change is that component edit's only trace.
  if (lastBoot !== null && e.data !== lastBoot) requestReload('reload');
  lastBoot = e.data;
});
es.addEventListener('reload', (e) => { lastError = null; requestReload(parseVerdict(e.data)); });

batchVerdict resetting inside emitReload rather than in requestReload is load-bearing: emitReload is the single place a batch ends, reached from both timers.

Step 5. Branch on the verdict in the reload client, packages/server/src/dev.js

5a. Refactor the readiness probe into one shared helper with two callers. __webjsReloadWhenReady at dev.js:3010-3019 becomes __webjsWhenReady(then), keeping the 100-try bound and keeping location.reload() as the exhaustion behaviour for both callers (a genuinely dead server should show the browser's own error page rather than hang):

function __webjsWhenReady(then) {
  var tries = 0;
  function attempt() {
    fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
      if (r && r.ok) then(); else again();
    }).catch(again);
  }
  function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
  attempt();
}
function __webjsApplyReload(verdict) {
  __webjsWhenReady(function () {
    // Runtime feature detection, never an assumption (#1398). The refresh entry
    // is published by enableClientRouter and removed by disableClientRouter, so
    // its ABSENCE covers both no-router cases at once: an app that opted out
    // with webjs.clientRouter:false, and a page that ships no component at all
    // so @webjsdev/core never loads in the browser.
    var refresh = globalThis.__webjsRefreshPage;
    if ((verdict === 'page' || verdict === 'shell') && typeof refresh === 'function') {
      refresh(verdict).then(function (ok) { if (!ok) location.reload(); },
                            function () { location.reload(); });
      return;
    }
    location.reload();
  });
}

5b. Update both message handlers to read m.verdict, replacing if (m.type === 'reload') __webjsReloadWhenReady(); at dev.js:3046 (the shim port) and dev.js:3058 (the SharedWorker port) with if (m.type === 'reload') __webjsApplyReload(m.verdict);. m.verdict is undefined for any message the relay did not stamp, and undefined is neither 'page' nor 'shell', so it reloads.

Note the existing test packages/server/test/dev/reload-shared-connection.test.js asserts the literal strings if (m.type === 'reload') __webjsReloadWhenReady() and else if (m.type === 'webjs-error') __webjsApplyError(m.data). Update those assertions in the same commit.

Step 6. The refresh entry, packages/core/src/router-client.js

6a. Hoist the existing full-body tier into a named function. Lines 3858-3877 are today the tail of applySwap, reachable only from the background paths. Extract verbatim, changing nothing:

/**
 * In-place FULL-BODY swap: merge the head, then replace every body child.
 * `mergeHead` PRESERVES stylesheets and `<style>` unconditionally (#936), so
 * this can never leave the page unstyled, and `regraftPermanentElements` adopts
 * each live `[data-webjs-permanent][id]` node by identity. Component instances
 * do NOT survive: every element is re-created and re-upgraded. Two callers: the
 * background snapshot-restore path in `applySwap`, and the `shell` mode of
 * `refreshPage` (#1398), which needs the layout's OWN markup replaced and
 * cannot get that from a boundary-range swap.
 * @param {Document} doc
 */
function swapFullBody(doc) {
  ingestSeeds();
  mergeHead(doc.head);
  regraftPermanentElements(document.body, doc.body);
  const newChildren = [...doc.body.childNodes];
  const doSwap = () => {
    document.body.replaceChildren(...newChildren);
    reactivateScripts(document.body);
    upgradeCustomElements(document.body);
    blurOutgoingFocus();
  };
  _swapCommit = runWithTransition(doSwap, () => upgradeCustomElements(document.body));
}

applySwap's tail becomes swapFullBody(doc);. This is a pure extraction, and the existing router browser suite is the regression check for it.

6b. Add a refresh option to performNavigation. Its signature at router-client.js:1788 is performNavigation(href, isPopState, frameId). Add a fourth positional opts:

async function performNavigation(href, isPopState, frameId, opts) {
  const refresh = opts && opts.refresh;   // 'page' | 'shell' | undefined (#1398)

Three existing lines then read the flag. Each is a deliberate suppression with its own reason:

  • router-client.js:1845, if (currentPageUrl) snapshotCurrent(currentPageUrl); becomes if (!refresh && currentPageUrl) snapshotCurrent(currentPageUrl);. A refresh navigates to the URL it is already on, so snapshotting first would write the pre-edit page into snapshotCache under that exact key and a later Back would restore it.
  • router-client.js:1860, if (!isPopState) optimisticState = applyOptimisticLoading(); becomes if (!isPopState && !refresh) .... Flashing a loading.ts skeleton over content that is already correct is strictly worse than showing the old content for one round trip. This mirrors Turbo, whose MorphingPageRenderer sets shouldAutofocus = false for the same reason.
  • router-client.js:1977, await fetchAndApply(href, frameId, !isPopState, ...) becomes await fetchAndApply(href, frameId, !isPopState && !refresh, ..., /* refresh */ refresh). Passing recordHistory: false is what suppresses both the duplicate history.pushState at :3043 and the whole scroll block at :3053-3070 in one flag, which is exactly what the comment above that block already says it means. Turbo reaches the same outcome through a dedicated shouldPreserveScrollPosition carve-out (turbo/src/core/drive/page_view.js:62-64); WebJs gets it from a flag that already exists.

6c. Suppress X-Webjs-Have on a refresh. fetchAndApply gains a trailing refresh parameter and skips the have-header at its buildHaveHeader() call site (router-client.js:2900). This is required, not an optimisation. The server short-circuits at the first layout whose segment path AND route key the client already holds (ssr.js:771-793), and a same-URL refresh matches every one of them, so the response would omit the layouts and a layout edit would be invisible. Sending no have-header forces the full chain to render.

6d. Select the tier by mode. applySwap gains a refresh argument threaded from fetchAndApply at router-client.js:3033. When refresh === 'shell', take swapFullBody(doc) directly, before the boundary scan. When refresh === 'page', fall through unchanged and let the existing two-tier logic morph the deepest shared boundary, which is what preserves outer-layout component state and is the behaviour the acceptance criteria name. Every existing degradation still applies to page mode: a poisoned or disjoint scan hard-navigates at :3840-3846, which is a correct and visible failure.

6e. The public entry. Add next to revalidate (router-client.js:740):

/**
 * Re-render the CURRENT url in place and apply it without a reload (#1398).
 * Dev-facing today (the live-reload client calls it for a page or layout edit),
 * but a plain capability with no dev-only code in it.
 *
 * Records no history entry and never scrolls, so the reader keeps their place.
 * `mode` picks the swap: `'page'` morphs the deepest shared boundary (outer
 * layout DOM and its hydrated components survive), `'shell'` replaces the whole
 * body (needed when the LAYOUT's own markup changed, since that markup lives
 * outside every children range).
 *
 * It does NOT reload changed component modules: `customElements.define` is
 * once-per-tag and a module URL is fetched once. A caller that changed browser
 * code must reload instead.
 *
 * @param {'page'|'shell'} [mode]
 * @returns {Promise<boolean>} whether the refresh applied. `false` means the
 *   caller should fall back to a full load.
 */
export async function refreshPage(mode) {
  if (!enabled || typeof location === 'undefined') return false;
  // Every cached copy predates the change, so drop both caches before fetching.
  revalidate();
  try {
    await performNavigation(location.href, false, null, { refresh: mode === 'shell' ? 'shell' : 'page' });
    return true;
  } catch (_) { return false; }
}

location.href carries no hash for a dev refresh in practice, and even with one the hash guards at :808 and :2285 are in onClick / eligibleAnchorHref and are never consulted here, so the fetch always happens.

6f. Publish and unpublish the global. In enableClientRouter() (router-client.js:584-586), after enabled = true, add globalThis.__webjsRefreshPage = refreshPage;. In disableClientRouter() (:635), after enabled = false, add delete globalThis.__webjsRefreshPage;. This global is the feature detection of Step 5a, and it covers both no-router cases without either side assuming anything: webjs.clientRouter: false makes the module-end auto-enable at :5398 skip enableClientRouter() entirely, and a page shipping no component never loads @webjsdev/core so the module never runs at all.

Step 7. Export surface

  • Re-export refreshPage from packages/core/index.js and packages/core/index-browser.js, beside navigate / revalidate on the existing export { ... } from './src/router-client.js' line (index-browser.js:63).
  • Declare it in packages/core/index.d.ts and in the client-router subpath overlay. packages/core/test/types/dts-export-coverage.test.mjs fails until both are declared.
  • Add refreshPage to CLIENT_ROUTER_IMPORTS in packages/server/src/component-elision.js:247. That list is how the analyser recognises a client-router import as client work; omitting the new name would let a page importing refreshPage be wrongly judged inert.

Tests

Counterfactuals are required in both directions and both are named below, per the acceptance criteria.

Unit, packages/server/test/

  • New, packages/server/test/dev/classify-watch-path.test.js. The pure classifyChangedPath ladder over a synthetic ctx: a page file returns page; a component file inside shippedFiles returns reload; a util reachable from a component returns reload; a .server.ts reachable only from a page returns shell; a layout returns shell; a public/app.css returns reload (this is the case a naive implementation gets wrong and the one that silently does nothing if morphed); a path outside appDir returns shell; analysisReady: false returns reload for every input. Plus strongerVerdict over all nine ordered pairs. Sibling naming follows watch-ignore.test.js and dev-regenerate.test.js in the same folder.
  • Extend, packages/server/test/dev/reload-shared-connection.test.js. It asserts the served client's literal strings, so it must be updated for the __webjsWhenReady / __webjsApplyReload rename, and gains an assertion that the served client contains the globalThis.__webjsRefreshPage feature detection and that location.reload() is still the fallback on both branches.
  • Extend, packages/server/test/listener/listener-core.test.js. Beside SseHub.reload fans a reload frame to every registered client, add that reload({ v: 'page', ... }) emits a parseable JSON data: line and that reload() with no argument emits {"v":"reload"}.
  • New, packages/server/test/dev/classify-live.test.js, modelled on the existing live-watcher tests watch-extra-paths-live.test.js and reload-retry-hint.test.js in the same folder (both boot startServer({ appDir, port: 0, dev: true }) and read /__webjs/events over node:http). Assert end to end that a page edit produces a frame whose data parses to v: 'page' and a component edit produces v: 'reload'. This is the counterfactual that fails if the filename is dropped at dev.js:1815 again, because both frames become identical. Add it to the DENYLIST in scripts/run-bun-tests.js alongside its two siblings, which are denylisted for reading the port via server.address().

Browser, packages/server/test/dev/browser/reload-worker.test.js (extend, do not create a sibling)

This is the file the source comments name, it imports the relay directly by relative path, and it already drives the debounce on a fake clock (fakeClock() at lines 34-65, FakeEventSource with .fire(type, data), fakePort()). Add a third suite, dev reload verdicts (#1398), matching the existing dev reload coalescing (#1397) naming:

  • a single reload frame carrying {"v":"page"} fans out { type: 'reload', verdict: 'page' } after tick(RELOAD_QUIET_MS).
  • the strongest-verdict rule: fire {"v":"page"} then {"v":"reload"} inside one quiet window and assert exactly one fanout with verdict: 'reload'.
  • order independence, the counterfactual for a last-write-wins implementation: fire {"v":"reload"} then {"v":"page"} and assert the fanout is still reload. A last-one-wins relay passes the previous test and fails this one.
  • a hello frame with a changed boot id contributes reload even when a {"v":"page"} frame is already in the batch.
  • parseVerdict resolves the legacy now, an empty string, null, {}, {"v":"bogus"} and malformed JSON to reload.
  • a new batch after an emit starts fresh, so a {"v":"page"} frame after a reload batch emits page.

Browser, new file packages/core/test/routing/browser/refresh-page.test.js

Follows the packages/*/test/**/browser/**/*.test.js glob in web-test-runner.config.js and the suite() / test() tdd style, importing assert from test/browser-assert.js. It must install installNavGuard() from test/browser-nav-guard.js, per the core package's browser-test rule, since a degradation inside refreshPage would hard-navigate and abort the whole session.

  • page mode morphs the boundary range, and a hydrated component outside that range keeps its state across the refresh. This is the headline assertion of the whole issue.
  • page mode preserves window.scrollY on a tall document, and records no history entry (history.length unchanged).
  • shell mode replaces the layout's own markup outside the children range, which page mode provably does not. Assert both halves in one test so the two modes are contrasted rather than asserted in isolation.
  • refreshPage() sends no X-Webjs-Have header. The counterfactual for the layout-invisibility trap: stub the fetch and assert the header is absent.
  • refreshPage() resolves false after disableClientRouter(), and globalThis.__webjsRefreshPage is undefined at that point and a function after enableClientRouter().

e2e, new file test/e2e/dev-morph.test.mjs

Siblings test/e2e/dev-overlay-nav.test.mjs and test/e2e/dev-seed-observability.test.mjs are the templates. Copy their stageApp() (cpSync a fixture into mkdtempSync, then symlink packages/core and packages/server into node_modules/@webjsdev), their freePort(), their startDev() spawning the real CLI with __WEBJS_DEV_CHILD: '1', and their puppeteer-core launch with executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium'. Gate with { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 to run E2E tests' }. New fixture at test/e2e/fixtures/dev-morph-app/ (a root layout with a marker in its own markup, a page with a marker, and a @click counter component).

__WEBJS_DEV_CHILD: '1' is load-bearing here and the reason this test is possible at all: it keeps the server in the spawned process instead of under the node --watch supervisor, which is the in-process path and the only one that can morph. State that in a comment, because a future reader will otherwise assume the test covers webjs dev as users run it on Node.

No existing e2e test writes to a staged app file (grep -l writeFileSync test/e2e/*.mjs is empty), so this is the first. Write into the staged temp copy, never the repo.

  • Scroll down, click the counter twice, edit the page file's marker, wait for the SSE-driven refresh, then assert: the new marker is on screen, scrollY is unchanged, the counter still reads 2, and no navigation occurred (record performance.navigation-equivalent via a window.__loads counter incremented by an inline script on each document load).
  • Counterfactual for the reload direction: edit the component file instead and assert the load counter incremented, that is, a real reload happened.
  • Edit the layout file and assert the layout's own marker updated and the load counter did not increment.
  • Add the file to CI beside the other per-file e2e invocations in .github/workflows/ci.yml (each e2e file is run as its own node --test step; the root test:e2e script covers only two of them).

Bun parity, test/bun/dev-morph-verdict.mjs plus its thin .test.mjs wrapper

This surface is runtime-sensitive and Bun parity applies. The SSE frame is emitted through SseHub._raw in listener-core.js, which both listener shells drive over different transports (node res.write versus a Bun ReadableStreamDefaultController), so the frame's new multi-line JSON data: payload is exactly the kind of thing that can differ between shells. test/bun/dev-extra-watch.mjs is the direct precedent: it boots a dev server, edits a watched file, and asserts a reload fires over SSE on both shells. Model the new file on it, asserting that a page edit yields a frame whose data parses to v: 'page' and a component edit yields v: 'reload', byte-identically on Node and Bun. Follow the pairing convention (the plain .mjs assert script plus a .test.mjs wrapper so the matrix picks it up) and add the plain script as its own CI step beside bun test/bun/dev-extra-watch.mjs.

The relay's debounce and the verdict accumulation are browser-side and have no Bun surface, which is why the Bun test targets the frame and not the relay.

Smoke and elision

No smoke test applies: test/examples/*/smoke/* boots an app and asserts served output, and this change alters no served page bytes in prod (the reload client 404s outside dev).

packages/server/test/elision/differential-elision.test.js must stay green. The one way this change could touch it is Step 7's addition to CLIENT_ROUTER_IMPORTS, which only ever makes the analyser ship more, so it cannot produce a wrong strip. Run it, plus test/elision/lifecycle-coverage.test.js and sigil-coverage.test.js, before opening the PR.

Commands to run and report

npm test, npm run test:browser, WEBJS_E2E=1 node --test test/e2e/dev-morph.test.mjs, node scripts/run-bun-tests.js plus bun test/bun/dev-morph-verdict.mjs, and ( cd gallery && npx webjs check ) / ( cd examples/blog && npx webjs check ) / ( cd website && npx webjs check ) with npx webjs doctor in each of the three.

Docs

refreshPage is new public API on @webjsdev/core and the dev-reload behaviour changes, so the doc gate applies and WEBJS_NO_DOC_GATE=1 must not be used. Invoke the webjs-doc-sync skill and touch every surface below.

  • AGENTS.md (repo root). Two edits. In the CLI reference's webjs dev line and wherever the dev reload is described, state that a page or layout edit refreshes in place and a component edit reloads, and state the reach honestly: the morph needs the server process to survive the edit, so it applies on Bun's bun --hot, under --no-hot, and for edits outside Node's five --watch-path dirs, while a node --watch restart always reloads. Add refreshPage to the public-API table beside navigate / revalidate.
  • AGENTS.md, the "Deliberately deferred" list. The "Vite-grade HMR with state preservation" entry must be clarified, not removed. It stays deferred. Add one sentence saying that Morph page/layout edits in place in dev instead of a full reload #1398 re-renders on the server and swaps the result, and hot-swaps no module, so a component edit still reloads and customElements.define is still once-per-tag.
  • packages/server/AGENTS.md, the dev.js row of the module map. Extend the existing "Graceful reload (dogfood: Node dev full-restarts on every app edit (downtime + CSS flash) #893)" / "Reload coalescing (dogfood: rapid edits leave the dev page unstyled (#893 residual gap) #1397)" prose with the classification: where the verdict is computed (dev-classify.js, against the shipped closure the elision pass already produced), that the SSE reload frame now carries a JSON payload whose absence or corruption resolves to a full reload, and that the relay takes the strongest verdict in a batch. Add a dev-classify.js row to the module-map table. Update the listener-core.js row for SseHub.reload(verdict).
  • packages/core/AGENTS.md, the router-client.js row. Note refreshPage(mode), the two swap tiers it selects, that it records no history and never scrolls, and that it suppresses X-Webjs-Have because a same-URL request would otherwise short-circuit past the very layout that changed.
  • .agents/skills/webjs/references/runtime.md. The Node-versus-Bun difference table gains a row: on Bun a dev edit to a page or layout refreshes in place, on Node the node --watch restart makes it a full reload. This is a genuine, user-visible runtime difference and belongs exactly here.
  • .agents/skills/webjs/references/client-router-and-streaming.md. Document refreshPage in the client-router surface beside navigate / revalidate.
  • packages/cli/templates/.agents/skills/webjs/references/runtime.md and .../references/client-router-and-streaming.md. The scaffold ships its own copy of the skill, so both edits above must be mirrored there or a freshly scaffolded app teaches the old reality.
  • Docs site. website/app/docs/ has no dev-server page today; the runtime and client-router pages are the homes. Verify with ls website/app/docs and put the runtime difference where references/runtime.md's content is mirrored, and refreshPage where navigate / revalidate are documented.
  • packages/core/index.d.ts and the client-router subpath overlay, per Step 7. These are enforced by packages/core/test/types/dts-export-coverage.test.mjs, not optional.

No README.md change: this is not a headline capability. No CONVENTIONS.md change: no new convention.

Acceptance criteria

  • Editing a page updates the browser in place with no full reload, preserving scroll position
  • Editing a layout updates the layout's own markup in place with no full reload, preserving scroll position
  • A hydrated component's state outside the changed region survives a page-edit refresh
  • Editing a component, or a module that transitively reaches one, still triggers a full reload
  • An unclassifiable change (a new file, a deleted file, a public/ asset, a cold analysis) falls back to a full reload, asserted rather than assumed
  • A batch mixing a page edit and a component edit inside one debounce window produces exactly one full reload, and the assertion is order-independent so a last-write-wins implementation fails it
  • A changed boot id always produces a full reload, even when a page verdict is already in the batch
  • An SSE reload frame with an absent, legacy, or unparseable payload produces a full reload
  • With { "webjs": { "clientRouter": false } }, and on a page that ships no component at all, the edit still reloads, detected at runtime through the absence of globalThis.__webjsRefreshPage rather than assumed
  • The refresh waits for server readiness through the same /__webjs/version probe the reload path uses, refactored into one helper with two callers rather than a copied loop
  • A refresh records no history entry, so Back still goes to the previous page
  • A refresh sends no X-Webjs-Have, proven by a test, so a layout edit is never short-circuited away
  • Counterfactuals prove both directions actually fire, and packages/server/test/elision/differential-elision.test.js still passes
  • Browser and e2e coverage, not unit alone, and a test/bun/** cross-runtime assertion on the new SSE frame
  • Docs updated on every surface listed above, including the clarified (not removed) deferred-HMR note and the honest statement of which dev modes get the morph

Out of scope

  • Module-level HMR. No prototype patching, no import.meta.hot, no re-registering a custom element. The deferred entry in AGENTS.md stays.
  • Making Node's default webjs dev morph. Narrowing --watch-path in packages/cli/lib/dev-supervisor.js would break deep-import edits, because only the route module itself gets the dev ?t= cache-bust and a transitively imported helper stays cached. Recomputing the verdict in the restarted process from file mtimes is a heuristic that misreads a git checkout or a save-all. Neither belongs in this issue.
  • Emitting the SSE frame earlier than the 80ms watch debounce to narrow the race against node --watch. It cannot close the race, so the boot-id rule stays either way, and it re-times what dogfood: rapid edits leave the dev page unstyled (#893 residual gap) #1397 and fix: stop rapid dev edits leaving the page unstyled #1400 just stabilised.
  • Per-route or per-client targeting of the refresh. Every connected tab gets the same verdict, like Next's coarse serverComponentChanges. The frame already carries by and why, so granularity is additive later without a wire change.
  • Any webjs start behaviour change. Dev only: reportDevError early-returns in prod and /__webjs/reload.js 404s there.
  • Re-fetching a changed public/ stylesheet on a refresh. It stays a full reload, because mergeHead preserves stylesheets unconditionally (dogfood: CSS drops on client-router soft nav on real Android Chrome (styled on refresh) #936) and changing that would reopen the unstyled-page class of bug.
  • Changing navigate(url, { replace: true }). Its replace flag currently lands in performNavigation's isPopState parameter, which is surprising, but it is pre-existing behaviour that nothing here depends on. Do not fold a fix for it into this PR.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions