Skip to content

feat: streaming SSR, abortable navigations, and speculation rules from janux.config.ts - #32

Merged
aralroca merged 11 commits into
mainfrom
feat/streaming-ssr-navigation
Jul 28, 2026
Merged

aralroca merged 11 commits into
mainfrom
feat/streaming-ssr-navigation

Conversation

@aralroca

Copy link
Copy Markdown
Owner

What

Navigation becomes what it was always meant to feel like: the server flushes the page as it renders, the client diffs it as it arrives, rapid clicks cancel the in-flight navigation (the last one wins), and prefetching is configurable from `janux.config.ts` with Speculation Rules emitted by default. Live on https://janux.build.

Streaming SSR (the actual fix for "streaming doesn't feel like streaming")

  • `renderToStream` — HTML flushes as it is produced, in document order, with sibling islands still loading their sources in parallel (a slow source holds back its own island, not the page). Joined chunks are byte-identical to `renderToString(...).html`, and `renderToString` is implemented as its drain. `cancel()` stops an abandoned render instead of paying for every island of a closed connection.
  • The shell splits into prelude/epilogue — title, meta and styles resolve from the route before the body renders, so the `` goes out as the first chunk; snapshots, island map and i18n payload follow the body. A byte-identity test keeps the streamed response and the buffered document from ever disagreeing.
  • `handlePage` answers with a `ReadableStream`; chunks coalesce on genuine renderer pauses (one flush per static run, not one write per tag). A render that fails after the first flush cannot change the status line anymore, so it reports in-page (`janux:error` + closed document; no auto-reload — a deterministic error would just repeat). Before the first flush it is still a normal 500. The Vite dev adapter pipes instead of buffering, honours backpressure, and survives a client that disconnects with the socket full.
  • Measured on the docs site: `<title>` at 8ms and first `

    ` at 59ms where previously nothing left the server until the last byte existed.

The renderer's internal design is ordered emitters (not async generators): siblings run under the exact `Promise.all` scheduling the string renderer always had — which is what keeps nested island key assignment aligned with the client's synchronous depth-first traversal (pinned by test; a generator-based draft assigned keys in yield order and two same-typed nested islands could swap state) — while the leftmost unfinished child streams straight through.

Abortable navigations (rapid clicks)

Navigations are serialized and each one cancels the one before it: the superseded fetch dies with its Navigation API signal, and a prefetched body — which has no fetch signal of its own — is wrapped so the navigation's signal kills it too (`abortableStream`). A superseded navigation never reports `after`, never disposes an island, and never wins over the last click. Verified in real Chrome: click two sidebar entries faster than the pages arrive → the last one lands, the assistant stays open, same live instance.

This surfaced two upstream bugs in `diff-dom-streaming`, both fixed with TDD in brisa-build/diff-dom-streaming#23 (v0.6.8):

  • the walker never applied the body progressively when content hangs off an open last child (`` after `` — i.e. every real page): only the `` streamed;
  • an erroring stream left `diff()` hanging forever — one aborted navigation froze SPA navigation until a hard reload.

Prefetch + Speculation Rules, configurable

```ts
export default defineConfig({
navigation: {
spa: true, // default: true
prefetch: { ttl: 60_000 }, // or false
speculationRules: { eagerness: 'moderate', exclude: ['/logout'] },
},
});
```

  • `janux.config.ts` finally reaches the client, through a keyed shell script (`jx-config`).
  • The shell emits `<script type="speculationrules">` by default (prefetch-only, `moderate`). Their cache only serves browser-driven document navigations — never a `fetch()` — so once `boot()` intercepts, the rules are narrowed to `a[data-native]` links (otherwise Chrome speculates documents the SPA path never uses, and hover fetches each page twice). Navigation responses ship them already narrowed, so the client's rescope is a no-op and the browser keeps its speculation candidates across the swap. Browsers without the Navigation API keep the document-wide rules — exactly the ones the API helps most.
  • Hover prefetch keeps the stream (the diff consumes it directly), is capped at 8 warmed pages with evicted bodies cancelled (a hover tour must not pile up open connections), respects Save-Data, and is skipped where no Navigation API would ever consume it.
  • A static page's single `<script>` tag is now the rules' JSON, which the browser reads as data: still zero JavaScript, and the page type that benefits most.

Persisted islands

A `persist` island the destination page does not render is disposed — that is the contract — but it reads as "my assistant closed itself", so it now warns naming the island and the route. Documented: render it from a shared layout and it survives the whole session.

Tests & docs

  • 3,333 tests green (unit + conformance corpus + docs truth guards), typecheck clean.
  • New browser e2e suite (`e2e/streaming-navigation.e2e.test.ts`, real Chrome via Playwright `channel: 'chrome'`): a navigation paints its heading mid-stream while the tail provably hasn't arrived; the assistant survives navigation open and same-instance; rapid navigations drop the superseded one; stylesheets never accumulate. CI builds the docs app first so the suite can't pass by silently skipping.
  • Docs updated with executable page tests: streaming (`guide/ssr-and-resumability`), navigation config, prefetch/speculation rules, persist contract (`guide/navigation`), `renderToStream`/`speculationRules` reference (`reference/core-api`).

Depends on

Follow-ups (noted, out of scope)

🤖 Generated with Claude Code

aralroca and others added 9 commits July 27, 2026 16:53
…tay parallel

The joined chunks are byte-identical to renderToString(...).html; islands
yield their open tag before loading sources, so a slow source holds back
its own children, not the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gue after it

shellParts splits htmlDocument into a render-independent prelude (title and
meta resolve from the route before the body renders) and a render-dependent
epilogue (snapshots, island runtime, i18n payload). A render that fails
after the first flush cannot change the status line, so it reports in-page
via janux:error and closes the document; no auto-reload, a deterministic
render error would just repeat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arrayBuffer() held every chunk until the slowest island resolved — exactly
what streaming SSR exists to avoid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n a persisted island is dropped

A prefetched body has no fetch signal, so the navigation's own signal now
kills the stream — without it a superseded diff kept consuming while the
newest navigation waited behind it in the chain. And a persist island the
destination page does not render disposes silently, which reads as 'my
assistant closed itself': dev builds now name the island and the route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rapid clicks

Two of these are red until diff-dom-streaming lands its walker fixes: the
body is not applied while a chunk's last node stays open (so a page whose
content hangs off <main> only streams its <head>), and a cancelled stream
leaves the diff hanging instead of rejecting. Both reproduce with the
library alone; the tests describe the behaviour Janux needs.

Also: a superseded navigation must not report success — the diff can
finish cleanly on a cancelled stream having applied only part of the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nux.config.ts

Speculation rules only apply to browser-driven navigations, so the shell
emits them for every internal link and boot() rewrites them to cover only
[data-native] links once it intercepts — otherwise Chrome speculates
documents the SPA path never uses and hover fetches the page twice. Hover
prefetch is now configurable (and skipped under data saver, or when there
is no Navigation API to make it useful), and janux.config.ts finally
reaches the client through a keyed shell script.

A static page's one script tag is now the rules' JSON, which the browser
reads as data: still zero JavaScript, and that page type is the one the
API helps most. The ZERO-JS test now says exactly that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The incoming page carries the server's document-wide rules and the diff
applies them faithfully, so from the second page on Chrome went back to
speculating documents the SPA path never uses — and hover fetched each
page twice. Caught in Chrome, not by the unit suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The browser suite skips itself without a client bundle, so without this it
would report green having run nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es across the stream path

The generator design suspended each sibling at its first yield, which
assigned nested island keys in yield order instead of the client's
synchronous depth-first order — two same-typed nested islands could swap
state on the parent's first re-render. Ordered emitters restore the exact
Promise.all scheduling the string renderer always had (keys identical to
main, pinned by test) while the leftmost unfinished child streams straight
through. It also drops the per-chunk generator-chain cost.

Also from review: chunks coalesce on genuine renderer pauses (one flush
per static run instead of one write per tag); RenderStream.cancel() stops
an abandoned render instead of paying for every island of a closed
connection; the shell splits into prelude/epilogue builders (nothing is
built twice per request); shellI18n owns the no-render shape; navigation
responses ship the rules already narrowed so the client rescope is a
no-op; prefetch caps warmed pages and cancels evicted bodies; the dev
adapter survives a client that disconnects with the socket full; shared
script ids are constants; the persist warning is unconditional like every
other developer-mistake warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

aralroca and others added 2 commits July 28, 2026 10:56
The two streaming e2e tests describe behavior that lands in
diff-dom-streaming 0.6.8 (brisa-build/diff-dom-streaming#23): the walker
applying children of an open last node instead of stalling the whole
body, and diff() rejecting on an erroring stream instead of hanging.
npm still serves 0.6.7, so CI ran without the fixes and those two tests
were red.

The vendored tarball is byte-identical to what `npm publish` will ship
(same shasum as the pack dry-run). TEMPORARY: swap the file: spec for
^0.6.8 as soon as the release is published — before any janux release,
since a file: dependency must never reach the npm registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ball

Published (same shasum as the vendored pack), so the temporary file: pin
lives no longer than one commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aralroca
aralroca merged commit c0e7c1e into main Jul 28, 2026
2 checks passed
@aralroca
aralroca deleted the feat/streaming-ssr-navigation branch July 28, 2026 09:06
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.

1 participant