Skip to content

fix: stream the body progressively past an open last node, and reject instead of hanging on stream errors - #23

Merged
aralroca merged 3 commits into
mainfrom
test/arbitrary-chunk-boundaries
Jul 28, 2026
Merged

fix: stream the body progressively past an open last node, and reject instead of hanging on stream errors#23
aralroca merged 3 commits into
mainfrom
test/arbitrary-chunk-boundaries

Conversation

@aralroca

Copy link
Copy Markdown
Collaborator

What

Two walker fixes plus a test hardening pass for arbitrary chunk boundaries. Version bumped to 0.6.8 (`deno.json` was stale at 0.6.6 and is now synced).

Fix 1 — the body was never applied progressively when content hangs off the last child

In the `nextSibling` hop, `isLastNodeOfChunk` fell through to `return streamInProgress` — an unconditional wait — even when the open node already had complete children to diff. Since real pages hang their content off the last child of a container (`

` after ``), the walk stalled before entering it and nothing of the body was applied until the stream closed; only the `` (which precedes it) streamed. Measured on a real docs site: during a 3s mid-stream pause, 43 mutations — all in ``, zero in the body.

The fix descends into an open last node once it has children (same condition the `firstChild` hop already used), and defers the pruning phase of `setChildNodes` for a level whose parent is still open — trailing nodes may yet arrive in a later chunk, so removing them early would corrupt the page. Deep clones settle before cloning so a half-parsed subtree is never photographed.

Fix 2 — an erroring stream hung `diff()` forever

`processStream()` only cleared `streamInProgress` on the `done` path. A stream that errors (an aborted fetch, a dropped connection) left it `true`, so the walker waited on `requestAnimationFrame` indefinitely: `diff()` neither resolved nor rejected, plus unhandled rejections from the fire-and-forget reader and `pipeTo`. For a SPA router this meant one aborted navigation froze all navigation until a hard reload.

Now the error is captured, `streamInProgress` clears in `finally`, the duplicate `pipeTo` rejection is silenced, and `diff()` rejects with the stream's error in bounded time.

Tests

TDD throughout — every fix landed with a failing test first. New coverage (ran on Chromium, Firefox and WebKit; 137 pass):

  • tag names, attribute names and attribute values split across chunks
  • multi-byte UTF-8 characters split at byte level across chunks
  • `<script>` semantics pinned: a NEW streamed inline script is inserted but not executed (parsed in the inert document, the "already started" flag survives cloning — identical in all three engines); a pre-existing script is not re-executed
  • stream error before/after the first chunk → `diff()` rejects
  • progressive body: `h1` inside ``-as-last-child updates mid-stream; trailing siblings added or removed by a later chunk reconcile correctly once the level closes

Harness additions: `newHTMLByteChunks` (raw byte chunks), `errorStreamMessage`, `midStreamEval` (DOM snapshot during the pause before the last chunk).

Why now

The Janux framework moved to streamed SSR (flushing many small chunks with boundaries anywhere) and its SPA navigation aborts superseded page streams — both fixes are prerequisites for that to work, and the chunk-boundary tests pin the contract it relies on.

🤖 Generated with Claude Code

Aral Roca and others added 3 commits July 27, 2026 18:11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lker

If the input stream errored (aborted fetch, dead network), the reader
rejection escaped as an unhandled rejection, streamInProgress stayed true
forever and the walker kept waiting on requestAnimationFrame: diff() never
resolved nor rejected. Now the error is captured, the stream is marked as
finished on both paths, and diff() rejects with the original error.

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

When the streamed content hangs off the LAST child of a still-open
container (the common <main> after <nav> layout), the nextSibling hop
waited unconditionally until the stream closed, so nothing below <body>
was applied progressively. Now the walker descends into an open node as
soon as it has children (same idea the firstChild hop already used),
null hops from the parser frontier wait for more nodes instead of ending
the level early (deferring the pruning of trailing old nodes until the
level is closed), and deep clones settle first so they cannot snapshot a
half-parsed subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aralroca
aralroca merged commit 9b3d8f9 into main Jul 28, 2026
1 check passed
aralroca added a commit to aralroca/Janux that referenced this pull request Jul 28, 2026
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>
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