Skip to content

fix: first-class dev hot-reload (no restart flash, watch outside appDir)#896

Merged
vivek7405 merged 5 commits into
mainfrom
fix/dev-hot-reload
Jul 10, 2026
Merged

fix: first-class dev hot-reload (no restart flash, watch outside appDir)#896
vivek7405 merged 5 commits into
mainfrom
fix/dev-hot-reload

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #893
Closes #894

Two dev-server hot-reload gaps found while previewing the website locally, fixed together.

What was wrong

#894 (watch outside the appDir). The dev watcher only follows the appDir recursively. An app that reads content from outside its tree (the website renders blog posts from a repo-root blog/ dir, a sibling of the app) got no reload when that content changed, so editing a post needed a manual browser refresh.

#893 (restart flash + manual refresh on app edits). On Node, webjs dev re-execs under node --watch, which restarts the whole process on an app edit. During the restart the server is briefly down, so the browser's live-reload could land on a still-restarting server and paint a style-less page ("CSS went off, only HTML appeared"). And when node --watch killed the process before the in-process rebuild's reload frame went out, no reload fired at all, so the edit needed a manual refresh.

The fix

  • dogfood: watch content dirs the app reads from outside its appDir #894: an opt-in webjs.dev.watch array of extra dirs the watcher follows alongside the appDir. Wired the website's blog/.
  • dogfood: Node dev full-restarts on every app edit (downtime + CSS flash) #893: a graceful client-side reload protocol (the approach Vite's client uses to survive a restart). Probe /__webjs/version before reloading (never paint into a down server), treat an EventSource reconnect-after-drop as the edit signal (so an edit whose reload frame was killed still reloads with no manual refresh), and a short retry: 300 on the SSE hello so that reconnect is prompt. Keeps Node's node --watch full restart, which is what makes deep transitive-import edits take effect (the dev ?t= cache-bust only refreshes the entry module). Design detail in a comment below.

Test plan

  • Unit: readDevWatchPathsFromApp (resolution, dedupe, overlap-skip, garbage-guard), 7 pass
  • Integration: live SSE reload on an outside-dir edit, the counterfactual (no config means no reload), and in-tree still reloads, 3 pass
  • Integration: reload client probes /__webjs/version before reloading, base-path aware, plus the live SSE retry: 300 hint
  • Cross-runtime (Bun): dev-extra-watch.mjs (dogfood: watch content dirs the app reads from outside its appDir #894) and dev-reload-retry.mjs (dogfood: Node dev full-restarts on every app edit (downtime + CSS flash) #893) green on Node AND Bun 1.3.14
  • Browser relay reconnect logic verified under Node. The in-browser reload-worker.test.js run happens in CI (local Playwright launch hangs in this sandbox)
  • All 199 packages/server/test/dev plus test/config tests pass
  • Dogfood: website / docs / ui-website boot 200/302 in prod (a dev-only change, the prod wire is byte-identical). Blog e2e is covered by CI

Docs

agent-docs/configuration.md (the webjs.dev.watch block), the WebjsConfig type (packages/core/src/webjs-config.d.ts), packages/server/webjs-config.schema.json, and packages/server/AGENTS.md (the dev.js reload mechanism).

@vivek7405 vivek7405 self-assigned this Jul 10, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design: why keep node --watch's restart instead of eliminating it (#893)

The issue proposed making the in-process fs.watch rebuild the default on Node and dropping the node --watch full restart, so an app edit would never restart the process. I started down that path and hit the wall that makes it unsafe: the dev ?t= cache-bust only refreshes the ENTRY module. handleApi / the page loader do import(url + '?t=' + Date.now()), which forces a fresh copy of that one module, but its STATIC transitive imports resolve to un-busted URLs and stay cached. So editing a helper deep in a page's import chain would go stale in-process, and only Node's full-process restart (a fresh ESM cache) picks it up. That is exactly why node --watch exists here, and it is documented as the "deep-import edit needs a manual restart" tradeoff on the --no-hot path. Bun avoids this because --hot invalidates loaded modules in place; Node has no equivalent, and building one (a module.registerHooks loader that busts the whole graph per generation) is a much larger, riskier change than this bug warrants.

So I kept the restart and fixed the thing the user actually feels: the restart being VISIBLE. The two symptoms both come from the browser reloading at the wrong moment relative to the restart, not from the restart itself:

  • CSS flash = the reload landed on a still-restarting (down) server. Fix: probe /__webjs/version before reloading, so the old page stays put until the fresh server is listening. Under an in-process reload the probe passes on the first try, so it stays instant.
  • "had to manually refresh" = node --watch killed the process before the in-process rebuild's reload frame went out, so no reload was ever delivered. Fix: treat the EventSource reconnect (drop then re-open, which only happens on a restart) as the edit signal, plus a retry: 300 so that reconnect is prompt instead of ~3s later.

This is the same shape as how Vite's client survives a server restart. Net effect: same restart latency as before (unavoidable, the new code isn't ready until the process restarts), but the restart is now invisible, no flash, no manual refresh. If we ever want true instant module-level reload on Node, that's the registerHooks-whole-graph-bust project, filed separately if it comes up, not this PR.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: graceful-reload protocol, one real state-loss vector

Went through the reconnect state machine, the overlap/dedupe logic, the emitted client JS, and the docs/schema. The #894 side and the probe-then-reload gate are solid. One genuine problem on the #893 side: the reconnect-reload fired on ANY EventSource drop-then-reopen, not just a real restart, so a laptop sleep/wake or a network blip would silently full-reload the tab and throw away in-page dev state. Fixed by keying the reload on a per-process boot id carried on the hello frame, so only a real restart reloads.

Comment thread packages/server/src/dev-reload-worker.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: boot-id reconnect mechanism, clean

Went back over the boot-id path end to end: the comparison in the relay and the fallback (first hello baselines, same-process reconnect is a no-op, a changed id reloads), that DEV_BOOT_ID is one module-scope value shared by both listener shells and stable across the dev re-import, the hello frame format, and the tests. Nothing to change.

An app that renders content from OUTSIDE its appDir (the website builds blog
posts from a repo-root blog/ dir, a sibling of the app) got no live-reload
when that content changed: the dev watcher only follows the appDir recursively,
so an edit to an outside file fired nothing and needed a manual refresh.

Add an opt-in webjs.dev.watch array: extra directories the dev live-reload
watcher follows alongside the appDir. Each entry resolves relative to the app
root and may escape it (../blog); a change under one runs the same rebuild +
browser reload as an in-tree edit. Missing paths are skipped, appDir overlaps
(ancestor/descendant) are dropped as redundant, and the usual node_modules /
.git / .webjs / db noise is ignored under each extra root. Wire the website's
blog/ dir so its previews reload.

Closes #894
…efresh

On Node, webjs dev re-execs under node --watch, which restarts the whole
process on an app edit. During the restart the server is briefly down, so the
browser's live-reload could land on a still-restarting server and paint a
style-less page (CSS gone, only HTML). And when node --watch killed the process
before the in-process rebuild's reload frame went out, no reload fired at all,
so the edit needed a manual refresh.

Fix the client-side reload protocol (the approach Vite's client uses to survive
a restart), 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):
- Probe-then-reload: gate every reload on a /__webjs/version readiness check, so
  a reload never paints into a half-restarted server. Instant under an
  in-process reload; waits out a restart otherwise.
- Reconnect-reload: a reconnect after a drop (a restart) is itself the edit
  signal, so an edit whose reload frame was killed with the old process still
  reloads with no manual refresh. Lives in the shared-connection relay and the
  per-tab fallback.
- A short retry: 300 on the SSE hello shrinks the EventSource reconnect backoff
  from its ~3s default so that reconnect is prompt, on both listener shells.

Closes #893
The reconnect-reload treated any EventSource drop-then-reopen as an edit signal,
but EventSource fires error on every transient drop (laptop sleep/wake, a
network blip, a tab evicted at the HTTP/1.1 connection cap). On such a reconnect
the version probe passes immediately (the server never restarted) and the tab
did a full reload anyway, discarding in-page dev state.

Stamp the SSE hello frame with a per-process boot id (DEV_BOOT_ID, regenerated
on every server start) and reload on reconnect only when that id CHANGES (a real
node --watch restart). A transient reconnect hits the same process with the same
id, so it never reloads. Replaces the open/error state machine in both the
shared-connection relay and the per-tab fallback.
watch-extra-paths-live.test.js and reload-retry-hint.test.js boot startServer
and read the port via node:http's server.address() shape, which the Bun.serve
shell does not expose, so bun test failed them. Their Bun parity is proven by
the dedicated test/bun/dev-extra-watch.mjs and dev-reload-retry.mjs scripts
(added as their own CI steps); denylist the node-only integration versions,
matching the existing dev-handler.test.js pattern.
@vivek7405 vivek7405 force-pushed the fix/dev-hot-reload branch from 7ebcedc to 4950173 Compare July 10, 2026 10:23
@vivek7405 vivek7405 marked this pull request as ready for review July 10, 2026 10:29
@vivek7405 vivek7405 merged commit 4fc7446 into main Jul 10, 2026
10 checks passed
@vivek7405 vivek7405 deleted the fix/dev-hot-reload branch July 10, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dogfood: watch content dirs the app reads from outside its appDir dogfood: Node dev full-restarts on every app edit (downtime + CSS flash)

1 participant