Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7f18870
feat: refresh page and layout edits in place in dev
vivek7405 Aug 13, 2026
6021479
test: browser coverage for the reload verdict relay and refreshPage
vivek7405 Aug 13, 2026
8baa77e
test: e2e proof that a dev page or layout edit never reloads
vivek7405 Aug 13, 2026
4b069c3
feat(gallery): demo refreshPage beside navigate and revalidate
vivek7405 Aug 13, 2026
57807f9
fix: read the refresh outcome, and re-request stylesheets after a swap
vivek7405 Aug 13, 2026
96b9c41
fix: keep the working stylesheet on a failed re-request, report applied
vivek7405 Aug 13, 2026
036a93d
fix: extract the stylesheet refresh so a browser test can drive it
vivek7405 Aug 13, 2026
ad48725
fix: force ok false on a superseded stream response
vivek7405 Aug 13, 2026
2fa8552
fix: complete the applied contract on loadFrame, and two doc slips
vivek7405 Aug 13, 2026
d2f80bd
fix: keep classifying after a rebuild, and stop claiming an uncommitt…
vivek7405 Aug 13, 2026
8b786c8
fix: report the uncommitted swap without changing the pipeline
vivek7405 Aug 13, 2026
bf18d84
test: cover the applied flag on the paths that commit nothing
vivek7405 Aug 13, 2026
b3b9868
test: make the URL-advance case observe its own click
vivek7405 Aug 13, 2026
364133f
test: move the URL parking inside the case's try block
vivek7405 Aug 13, 2026
31dd537
test: assert the history call instead of dancing the URL around
vivek7405 Aug 13, 2026
11302ab
fix: dismiss a live error overlay before an in-place refresh
vivek7405 Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .agents/skills/webjs/references/client-router-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What This Covers

- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()`.
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()` / `refreshPage()`.
- Link prefetch with device-adaptive defaults.
- `<webjs-frame>` partial-swap regions (WebJs's Turbo Frames).
- View Transitions opt-in.
Expand Down Expand Up @@ -56,6 +56,20 @@ revalidate(); // clear the entire snapshot cache

The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.

**In-place refresh of the page you are on.** `refreshPage(mode)` re-renders the CURRENT url on the server and applies it without a page load.

```js
import { refreshPage } from '@webjsdev/core';
await refreshPage(); // 'page': morph the deepest shared boundary
await refreshPage('shell'); // replace the whole body (the layout's own markup changed)
```

It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page. `'page'` morphs the deepest shared boundary, so the outer layout's DOM and the hydrated state of its components survive; `'shell'` replaces the whole body, which is what a LAYOUT change needs, since a layout's own header, nav, and footer sit outside every children range and a boundary morph would leave them untouched. Component instances do not survive a `'shell'` refresh.

It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first layout the client already holds, and a same-url request matches every one of them, so the response would omit the very layout that changed. It resolves `false` when it did not apply (the router is disabled, or the fetch failed), so a caller falls back to a full load.

It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh).

**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:

- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/webjs/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
| Hot reload | `node --watch` | `bun --hot` |
| WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
| Dev edit to a page / layout | full reload (the `node --watch` restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |

**The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.

Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. Node's `bun --hot` equivalent is `node --watch`, which RESTARTS the process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
Comment thread
vivek7405 marked this conversation as resolved.

The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.

Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,18 @@ jobs:
# handle() differently (the Bun shell may rebuild the Request and
# classifies the body through readBufferedOrStream), so the ordering has
# to be proven on both.
- name: webjs dev extra-watch + reload-retry + overlay-scope + public-before-warm on Bun
# dev-morph-verdict rides here too: the live-reload frame's `data:` line is
# now a JSON verdict payload rather than a bare token (#1398), and the two
# listener shells drive `SseHub._raw` over different transports (node
# `res.write` versus a Bun ReadableStream controller), so the frame has to
# be proven byte-identical on both.
- name: webjs dev extra-watch + reload-retry + overlay-scope + public-before-warm + morph-verdict on Bun
run: |
bun test/bun/dev-extra-watch.mjs
bun test/bun/dev-reload-retry.mjs
bun test/bun/dev-overlay-scope.mjs
bun test/bun/dev-public-before-warm.mjs
bun test/bun/dev-morph-verdict.mjs
# The app-source deploy signal (#899) is derived from an fs source walk +
# a node:crypto digest, so it must be byte-identical on the Bun.serve path.
- name: App-source deploy signal on Bun
Expand Down Expand Up @@ -420,6 +426,18 @@ jobs:
env:
WEBJS_E2E: '1'
run: node --test test/e2e/dev-seed-observability.test.mjs
# The in-place dev refresh (#1398): spawns `webjs dev` against a fixture app
# and drives a real browser, because every criterion is a browser fact. A
# page or layout edit updating WITHOUT a reload is not observable from the
# DOM (the fixture stamps a per-document token to tell the two apart), and
# a hydrated component surviving the swap needs a real element upgrade.
# This is also the only layer that exercises the whole loop end to end:
# the watcher classifies, the SSE frame carries the verdict, the relay
# coalesces it, and the tab applies it.
- name: Run dev-morph e2e (#1398)
env:
WEBJS_E2E: '1'
run: node --test test/e2e/dev-morph.test.mjs
# Form-submission, concurrent-nav and scroll-restoration e2e (#1310). This
# one runs against the WEBSITE rather than the blog, because the scroll
# case needs a page whose content settles taller after the swap, which is
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
| `asset(path)` | Content-hash a `public/` asset url so a deploy cannot serve stale bytes: `href=${asset('/public/app.css')}` emits `?v=<hash>` and is served `immutable` for a year (#1194). Prod-only, `public/` paths only. Use it in a PAGE, LAYOUT, or metadata route, not inside a component that ships: hydration re-renders on the client where there is no resolver, so the hashed url is swapped for the bare path and the asset is fetched twice. Call it inside the render function, since a module-scope call is a side effect that ships the module. Mark only files that change with a DEPLOY (the hash is memoized for the process lifetime). Opt-in on purpose: do NOT mark a `rel=preload` hint whose asset is fetched by CSS `url()`, or the preload can never match. |
| `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
| `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
| `refreshPage(mode?)` | Re-render the CURRENT url on the server and apply it in place, with no reload (#1398). Records no history entry and never scrolls. `'page'` (default) morphs the deepest shared boundary, so hydrated component state outside it survives; `'shell'` replaces the whole body, which a LAYOUT change needs because its own markup sits outside every children range. It reloads no component module (`customElements.define` is once-per-tag), so a caller whose change touched browser code must reload instead. This is what the dev live-reload client calls for a page or layout edit. |
| `optimistic(signal, value, action)` / `optimistic(host, { source, update })` | Imperative: set `signal` immediately, run `action`, roll back on error. Declarative (preferred): queue optimistic updates with auto-release via `.add(payload, promise?)`. See `references/client-router-and-streaming.md`. |
| `renderStream(payload)` / `WebjsFrame` | `<webjs-stream>` element-level updates (#248); `<webjs-frame>` partial-swap regions (#253). See `references/client-router-and-streaming.md`. |
| `Metadata` / `PageProps<R>` / `LayoutProps<R>` / `RouteHandlerContext<R>` / `WebjsConfig` (type-only) | Types for metadata, page/layout/route args (`R` narrows `params` against the `webjs types` route union), and the `webjs` config block. See `references/routing-and-pages.md` + `references/built-ins.md`. |
Expand Down Expand Up @@ -517,7 +518,7 @@ Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a
## CLI reference

```sh
webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550)
webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550). A page or layout edit REFRESHES IN PLACE where the server process survives it (#1398); a component edit always reloads
webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550)
webjs test [--server] [--browser] [--watch]
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
Expand Down Expand Up @@ -636,6 +637,6 @@ Call it from a client component via a normal import (rewritten to an RPC stub).
Not in v1. Do not implement as part of other tasks:

- **Bundling and per-route code splitting.** WebJs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
- **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE.
- **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE. #1398 does not change this: it 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.
- **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
- **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).
9 changes: 8 additions & 1 deletion gallery/app/features/client-router/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ export default function ClientRouterExample() {
<a href="/features/client-router/second" class="${buttonClass()} no-underline">Go to page two</a>
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
</div>
<p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
<p class="text-muted-foreground text-sm mt-6">
Rendered on the server at
<code class="font-mono">${new Date().toISOString().slice(11, 19)}</code> UTC.
This page function runs only on the server, so this stamp changes on every
render, which is what makes <code class="font-mono">refreshPage()</code>
below visible.
</p>
<p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code> / <code class="font-mono">refreshPage()</code>:</p>
<router-controls></router-controls>
<p class="text-muted-foreground text-sm mt-6">
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
Expand Down
18 changes: 16 additions & 2 deletions gallery/modules/client-router/components/router-controls.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
// swap an <a> click does, but from an event handler (after a save, a wizard
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
// visit refetches fresh HTML instead of the cached page. `disableClientRouter()`
// visit refetches fresh HTML instead of the cached page.
Comment thread
vivek7405 marked this conversation as resolved.
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
// result in place, recording no history entry and never scrolling, so the reader
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
// hydrated component state outside it survives; 'shell' replaces the whole body,
// which is what a layout change needs. `disableClientRouter()`
// / `enableClientRouter()` turn soft navigation off / back on at runtime (for a
// moment where you want a full page load, e.g. handing off to a third-party
// flow). disableClientRouter() removes the document-level <a>/<form> click
Expand All @@ -11,7 +16,7 @@
// All are client-only (they run in the browser), so a component is the right
// home; a page/layout never hydrates. With JS off the plain link still works
// (progressive enhancement), while the buttons are inert.
import { WebComponent, html, signal, navigate, revalidate, disableClientRouter, enableClientRouter } from '@webjsdev/core';
import { WebComponent, html, signal, navigate, revalidate, refreshPage, disableClientRouter, enableClientRouter } from '@webjsdev/core';
import { buttonClass } from '#components/ui/button.ts';

export class RouterControls extends WebComponent {
Expand All @@ -35,10 +40,19 @@ export class RouterControls extends WebComponent {
<button
@click=${() => revalidate()}
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
<button
@click=${() => refreshPage()}
class=${buttonClass({ variant: 'link', size: 'none' })}>refreshPage() this page</button>
<button
@click=${() => this.toggleRouter()}
class=${buttonClass({ variant: 'link', size: 'none' })}>${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
</div>
<p class="text-sm text-muted-foreground">
refreshPage() re-renders THIS url on the server and swaps it in.
The server time above updates, and your scroll position does not
move. On a page with hydrated components outside the swapped region,
Comment thread
vivek7405 marked this conversation as resolved.
their state survives too.
</p>
<p class="text-sm text-muted-foreground">
Plain link:
<a href="/features/client-router/second" class="text-primary underline">/features/client-router/second</a>.
Expand Down
Loading
Loading