Skip to content

perf(core): one-pass SSR renderer compiled per template - #1647

Merged
vivek7405 merged 24 commits into
mainfrom
perf/one-pass-ssr-renderer
Oct 27, 2026
Merged

vivek7405 merged 24 commits into
mainfrom
perf/one-pass-ssr-renderer

Conversation

@vivek7405

@vivek7405 vivek7405 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1646

What

The server renderer made three passes over every render: renderTemplate walked each template character by character on every call, inertRanges re-tokenised the output, and injectDSD regex-scanned it once per nesting level, instantiating each component and splicing the result back in. That is why its cost was flat in page size and paid per component (about 15 µs per component before this PR).

Now render-server/compile.js tokenises a template ONCE per TemplateStringsArray identity into ops (static chunks, holes with their position, element opens and closes, and the comment / raw-text / text-only contexts the rescans used to recover), walk.js interprets the ops with a buffered or a streaming sink, and expand.js instantiates a registered custom element as its ops go by, capturing its authored children and projecting them into its own template structurally. Nothing is scanned twice and every instance renders exactly once. The registry is consulted at render time only, so a tag primed before its class registers still expands (#1645). injectDSD(html) remains as the string-input path (streamed boundary children, unsafeHTML), through the same tokenizer. A boundary nested inside a streamed boundary's children carries its rendered .fallback as data-webjs-fallback (what the old machine emitted for every boundary) until its parent's children are expanded at stream time.

Three further commits take the per-instance floor down: the reactive accessors are installed on the class prototype once per class instead of on every instance (a component now costs about 0.07 µs to construct instead of 1.05), a frame's start tag accumulates in its own buffer so taking it never slices the page built so far (a JavaScriptCore rope cost), and the empty-template check stops at the first non-whitespace character.

A server-side change closes the one whole-server row WebJs could still lose, the compressed pair on Bun: a buffered body under 32 KB is gzip-encoded even when brotli is accepted, because at that size gzip level 6 is 2 to 3x cheaper than brotli quality 4 on both runtimes for 1 to 8 percent more bytes (912 vs 986 bytes on the 2.4 KB counter page), while above 32 KB brotli's ratio advantage crosses over and it stays preferred; a streamed body keeps brotli and a client accepting only brotli gets brotli at any size. Two per-request costs from the same Bun profile go with it: the prod import memo is consulted before pathToFileURL is paid, and the https check reads the scheme off the request url instead of parsing it.

Engine ratios (interleaved, same process, this host)

The final table on the PR head is in the last verification comment; the one below is from the first measurement, kept for the before/after story.

corpus page Node, before Node, after Bun, before Bun, after
page + 1 component, vs React renderToString 0.83 0.82 1.16 0.48
10 slotted components, vs React renderToString 1.46 0.72 2.53 1.00
2.5 KB page + component, vs React renderToString n/a 0.67 n/a 0.66
10 slotted components, vs React renderToReadableStream n/a 0.68 n/a 0.85

Lower is better; a ratio of 1.0 is parity. Absolute microseconds swing 3 to 4x between runs on this host, so only within-run ratios are reported. Harness: bench-harness/engine-bench.mjs (outside the repo), 3k warm-up, 10k to 20k rounds, both libraries interleaved in one process. A CPU profile of the one-component page after the change shows the component constructor as the top frame and no render-server frame that scans rendered output.

Deliberate behaviour changes (each a golden update in its own commit)

  • A light child of a shadow host now sees the host through closest(); the old walk omitted the shadow host from its light children's ancestor chain, which does not match the DOM.
  • A hole after a <script> or <style> whose start tag ended on an attribute (<script defer>) was emitted RAW until the next close tag, because the old hole machine re-entered raw text on the END tag. One tokenizer ends raw text where the spec does, so that hole is escaped.
  • Suspense boundary ids and the modulepreload set are assigned in strict document order (ids are only matched by value).
  • Each instance renders once; the old walk rendered a nested instance once per chain depth and discarded the outer results.
  • An attribute hole after whitespace around = (title= ${x}, href = ${x}) binds; the old machine dropped it.
  • Holes inside the dogfood: SSR misses the script-data-double-escaped state #1134 double-escaped script region are raw to the real script end.
  • A <webjs-suspense> inside a light-DOM component's authored children streams correctly. The old renderer processed it twice (once at the top level, once in the component's recursive pass over the projected children), emitting an empty-fallback placeholder under a second id and streaming the fallback as content, so the real children never landed.

Gates

  • Golden corpus of 89 fixtures generated by the three-pass renderer before any source change (packages/core/test/rendering/ssr-golden/), green after it apart from the two documented diffs.
  • Contract tests: ssr-one-pass.test.js (render-once counterfactual, compiled-path inert contexts, structural slot partition), ssr-walker-edges.test.js, ssr-walker-branches.test.js, ssr-render-memos.test.js rewritten for the compile cache (bypassing it fails the count assertion). Every render-server module is at 100 percent line coverage.
  • Bun: test/bun/ssr-one-pass-parity.mjs renders the whole corpus under either runtime and holds it to the fixtures, with a CI step.
  • See the verification comment below for the suites run.

Doc surfaces

  • packages/core/AGENTS.md: the render-server.js, webjs-suspense.js and component.js rows and the slot invariant describe the one-pass structure and the prototype accessors.
  • packages/server/AGENTS.md and references/runtime.md: the small-body gzip preference.
  • website/app/docs/ssr/page.ts: the pipeline lists component expansion as part of the render walk.
  • Root AGENTS.md, skill references, scaffold: N/A, no public API changes.

89 cases rendered through the public renderer and committed as fixtures, so the one-pass rewrite (#1646) is held to the three-pass renderer's exact output and every deliberate difference lands as a named golden update.
@vivek7405 vivek7405 self-assigned this Oct 27, 2026
@pilots-run

pilots-run Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Preview for 9b15da7 was not built: compose file has unsupported keys

Next: fix the listed keys in the compose file

t added 9 commits October 27, 2026 19:25
The server renderer made three passes over every render: renderTemplate
walked every template character by character on each call, inertRanges
re-tokenised the output, and injectDSD regex-scanned it once per nesting
level, instantiating each component and splicing the result back in, which
is why its cost was flat in page size and paid per component.

Now compile.js tokenises a template ONCE per TemplateStringsArray identity
into ops (static chunks, holes with their position, element opens and
closes, the text-only and raw-text contexts the rescans used to recover),
walk.js interprets the ops with a buffered or a streaming sink, and
expand.js instantiates a registered custom element as its ops go by,
capturing its authored children and projecting them into its own template
structurally. Nothing is scanned twice and every instance renders once.
The registry is consulted at render time only, never at compile time, so
a tag primed before its class registers still expands (#1645).

Two goldens move, both deliberate. A light child of a shadow host now
sees the host through closest(), which the old walk omitted from the
chain. A hole after a script or style whose start tag ended on an
attribute (`<script defer>`) was emitted RAW until the next close tag,
because the old machine re-entered raw text on the END tag; one tokenizer
ends raw text where the spec does, so that hole is escaped.

Refs #1646
The #1644 regex memos (tagPattern, the per-tag close regexes) have no
caller left, so html-scan.js keeps only the element-class predicates and
the memo test now proves the one memo that remains: a template tokenizes
once per strings identity (bypassing the cache fails the count
assertion), a class registered or primed after the template compiled
still expands, and the ops record every hole position and element edge.
The `<?` opener is matched by regex so the sigil guard keeps its meaning.

Refs #1646
A start tag, an end tag and a primitive child hole no longer go through
an async hop each (only a frame-taking tag returns a promise), the
per-tag state allocates its form bookkeeping lazily, a component begins
synchronously and settles an async render() separately, one attribute
parse serves both the instance and the present-name set, the slot and
partition allocate only when a named segment exists, the attribute-backed
prop list is memoized per class, and the two escapers skip their three
replaces when the string carries nothing to escape.

Interleaved against React on this host, the ten-slotted-component page
went from 1.20x to 0.72x of renderToString on Node and from 1.43x to
1.00x on Bun, with the one-component and 2.5 KB pages at 0.48x to 0.82x.

Refs #1646
ssr-one-pass.test.js pins what the goldens cannot name: every instance
renders exactly once however deep it nests (the rescanning walk rendered
a nested instance once per chain depth), the inert contexts hold for a
nested template VALUE inside a comment, raw text, a text-only element or
an attribute value, the structural slot partition routes every top-level
child shape, and an unclosed child runs to the end of the authored
children. The tokenizer gains the after-attribute-name state, so a hole
after whitespace around `=` binds instead of being dropped.

test/bun/ssr-one-pass-parity.mjs renders the whole golden corpus under
whichever runtime runs it and holds it to the committed fixtures, with a
CI step under Bun.

Refs #1646
The render-server module map row, the webjs-suspense row and the slot
invariant now describe compile.js, walk.js and expand.js instead of the
injectDSD rescan, and the docs site SSR pipeline page lists component
expansion as part of the render walk rather than a scan over its output.

Refs #1646
until() with only promises and with every candidate rejecting,
templateContent() on both sinks, a value with no renderer case, a dropped
hole position at render time, a renderError() that throws as well, a
whitespace-padded end tag closing a component frame, and the string-input
injectDSD() entry including an unclosed component. template-renderer.js
loses render(), which nothing imported after the one-pass wiring, so
every render-server module is at 100 percent line coverage.

Refs #1646
A bare registered class, a non-Error throw, slot names in every quoting with the default aliases and extra attributes, watch / ref / asyncAppend at SSR, a self-closed boundary, an outer end tag closing an unclosed inner frame, a processing instruction, a stray < and a template ending inside a tag name.

Refs #1646
…reads

The architecture guard does not collect method parameters, so a bare fn() in Walker.capture read as a lost import. Refs #1646
A <webjs-suspense> met while a streamed boundary's children were being
captured lost its .fallback (the hole rendered into per-tag state the
ordinary-element path never read) and its end tag closed the OUTER
boundary's frame, since an ordinary element sharing a frame's tag was not
counted as nesting inside it. The fallback now rides the tag as
data-webjs-fallback, exactly what the old machine emitted, and is read
back and expanded when the parent's children are rendered at stream
time; a same-tag element inside a deferred region is counted on the
nearest frame with that tag so only its own end tag matches it. An
errored component no longer pushes a null ancestor.

Four goldens cover the shapes. Three carry the bytes the three-pass
renderer produced. The fourth, a boundary inside a light-DOM
component's authored children, carries the CORRECT bytes instead: the old
renderer processed that boundary twice (once at the top level, once in
the component's recursive pass over the projected children), emitting an
empty-fallback placeholder under a second id and streaming the fallback
as if it were content, so the real children never landed.

Refs #1646
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Verification on the final head (be202ca)

Run in a clean clone with a real npm ci (the primary checkout's install is stale), core dist rebuilt from the branch.

suite result
npm test (node, every package) 5191 pass, 0 fail, 2 skipped (the Redis round-trip)
Bun matrix (node scripts/run-bun-tests.js) 392 pass, 29 documented node-only skips, 0 genuine failures
browser x3 (npm run test:browser) Chromium 981, Firefox 971, WebKit 981 passed, 0 failed (same counts as main)
e2e, every test/e2e/*.test.mjs with the website dev server up for the form files 149 pass, 0 fail
webjs check on gallery, blog, website all checks pass
webjs doctor on gallery, blog, website 14 passed, 2 warnings, 0 failed on each (the same warnings as main)
webjs elision --verify on gallery, blog, website identical verdicts to main: 1 of 32 gallery routes diverges on both, a clock line on the caching demo that changes between the two renders
real-app byte diff, 119 static routes of the three apps rendered in prod mode on main and on the branch identical apart from clocks, a pid, the website's data-webjs-src fingerprint (the SSR docs page changed) and that page's rewritten pipeline section
golden corpus (93 fixtures) under Node and under Bun byte-identical
renderer modules, line coverage compile.js, walk.js, expand.js, errors.js, dsd.js, html-scan.js, attrs.js, template-renderer.js all at 100 percent (branch 95 to 100)

A CPU profile of the one-component page after the change (60k renders): the component constructor (_initializeProperties) is the top frame at about 12 percent, then the op loop and tagEnd; no render-server frame scans rendered output.

Engine ratios on this host, interleaved in one process (lower is better, 1.0 is parity with React):

page Node vs renderToString Node vs renderToReadableStream Bun vs renderToString Bun vs renderToReadableStream
page + 1 component 0.82 0.65 0.48 0.41
10 slotted components 0.72 0.68 1.00 0.85
2.5 KB page + component 0.67 0.36 0.66 0.40

Before this PR the ten-component page sat at 1.46 (Node) and 2.53 (Bun) against renderToString.

@vivek7405
vivek7405 marked this pull request as ready for review October 27, 2026 14:38
t added 3 commits October 27, 2026 20:40
Every component instance defined a getter and a setter per declared
property on itself, two closures and a defineProperty each, which made
construction cost about 1 us per instance and was the largest single frame
left in the SSR profile once the renderer became one pass (one instance is
built per rendered element). The accessors now live on the class prototype,
installed once per class with a per-class plan; the constructor only lifts
an own value written before upgrade into the backing store and applies the
declared defaults, so an instance costs about 0.07 us. The controller set
is created by the first addController, and updateComplete starts as one
shared settled promise instead of a promise per instance.

The blog-like engine page (nav, 20 cards with slots, 60 tags) went from
616 to 334 us on Node.

Refs #1646
A registered element, a boundary or a light-DOM slot now accumulates its
start tag in a fresh buffer from its `<`, so taking it at the `>` never
slices the page built so far; on JavaScriptCore that slice materialised
the whole rope on every component. The empty-template check stops at the
first non-whitespace character instead of copying the rendered template
through trim(), and the attribute loops iterate the parsed map directly.

Blog-like engine page: Node 334 to 294 us, Bun about 560 to 410 us.

Refs #1646
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Final comparison on the PR head (3b5ec87)

Everything below is one window on one host, each pair interleaved in the same process or the same run, so only ratios are meaningful (absolute microseconds swing several-fold here). Lower is better, 1.0 is parity.

Render engine, WebJs vs the React build TanStack Start resolves (react-dom 19.3.1)

TanStack's renderRouterToStream calls renderToReadableStream wherever it exists (Node 24 and Bun both have it) and renderToPipeableStream otherwise, so the stream column is the exact comparator; renderToString is React's buffered renderer, which TanStack does not call.

page runtime main, vs stream branch, vs stream main, vs renderToString branch, vs renderToString branch, vs pipeable
page + 1 component Node 0.40 0.19 0.38 0.15 0.26
10 slotted components Node 1.50 0.57 1.97 0.66 0.50
2.5 KB page + component Node 0.83 0.35 1.43 0.63 0.28
blog-like: nav, 20 cards with projected children, 60 tags (61 instances) Node 2.39 0.52 2.55 0.55 0.51
page + 1 component Bun 0.68 0.50 0.77 0.58 0.53
10 slotted components Bun 1.88 0.66 2.22 0.87 0.53
2.5 KB page + component Bun 1.10 0.35 1.61 0.58 0.47
blog-like (61 instances) Bun 3.93 0.97 4.20 1.09 0.67

On main the engine was behind React on every page but the trivial counter: 1.5x to 2.4x on Node and 1.9x to 3.9x on Bun against the stream renderer. On the branch it is under parity on every page and runtime against the renderer TanStack uses, with the one near-parity cell being the 61-instance page on Bun (0.97 stream, 0.67 pipeable, 1.09 buffered).

Whole server, the two matching counter apps (CPU seconds per 1,000 requests)

mode runtime TanStack Start WebJs main (same window) WebJs branch
identity (render only) Node 0.230 to 0.319 0.176 (0.62x) 0.140 (0.45x to 0.61x)
identity (render only) Bun 0.259 to 0.273 0.160 (0.62x) 0.153 (0.56x)
compressed (WebJs brotli on, TanStack never compresses HTML) Node 0.231 to 0.345 0.250 (1.08x) 0.225 (0.65x)
compressed Bun 0.201 to 0.300 0.260 (0.87x) 0.279 (0.93x to 1.39x)

Requests per second on Bun, identity: WebJs 9,001 vs TanStack 5,558. The compressed Bun pair measures a default TanStack does not pay (a brotli pass per page); the branch's identity-to-compressed delta on Bun is the pre-existing cost #1645 already flagged for re-verification, not the renderer.

Verification on this head

suite result
npm test (clean clone, real install) 5191 pass, 0 fail, 2 skipped (Redis)
Bun matrix 0 genuine failures
browser x3 Chromium 981, Firefox 971, WebKit 981 passed, 0 failed
e2e, every test/e2e/*.test.mjs with the website up 149 pass, 0 fail
golden corpus (93) under Node and Bun byte-identical
CI every job green (see the checks)

The three commits since the first verification comment: reactive accessors installed on the class prototype once per class (constructor cost 1.05 µs to 0.07 µs per instance; browser x3 and e2e re-run green), a frame's start tag taken without slicing the page buffer (the JavaScriptCore rope cost), and a non-copying blank check.

t added 2 commits October 27, 2026 21:06
The compressed pair against TanStack Start (which never compresses HTML)
was the one row WebJs could still lose on Bun, and the cost was brotli
itself: on a 2.4 KB SSR page brotli quality 4 takes 60 to 82 us on the two
runtimes against 18 to 29 us for gzip level 6, for 912 against 986 bytes.
The byte gap shrinks to 3 percent at 8 KB and vanishes by 32 KB, where
brotli ratio advantage and Node slower gzip cross over (a 133 KB page is
36.3 KB either way). So a buffered body under BROTLI_MIN_BYTES (32 KB) is
gzip-encoded when the client accepts both; a streamed body keeps brotli
(its size is unknown when the head goes out), and a client accepting only
brotli gets brotli at any size. Both shells re-negotiate with the size on
the sync path through the one shared seam.

Two per-request costs the same profile surfaced go with it: the prod
import memo is consulted before pathToFileURL is paid, and the https
check reads the scheme off the request url instead of parsing it.

Refs #1646
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Compressed pair closed (d0e818f)

With a buffered body under 32 KB gzip-encoded when the client accepts both encodings (brotli stays preferred above 32 KB, on streamed bodies, and for a client that accepts only brotli), the whole-server rows on this head, CPU seconds per 1,000 requests, each pair interleaved in one window:

mode runtime TanStack Start (plain HTML) WebJs ratio req/s WebJs vs TanStack
identity Node 0.245 0.145 0.59 7,452 vs 5,832
identity Bun 0.275 0.141 0.51 9,292 vs 5,445
compressed (WebJs gzip per page) Node 0.309 0.227 0.73 4,947 vs 4,418
compressed (WebJs gzip per page) Bun 0.272 0.221 0.81 5,851 vs 5,586

Before this commit the compressed Bun row was 0.279 against 0.201 to 0.300 (brotli quality 4 on every page, 60 µs of a 2.4 KB response on Bun). The codec measurements behind the 32 KB floor are in the listener-core.js comment on BROTLI_MIN_BYTES: on the 2.4 KB counter page gzip 6 costs 18 to 29 µs for 986 bytes against brotli 4's 60 to 82 µs for 912 bytes; at 133 KB both compress to 36.3 KB and brotli is the cheaper of the two on Node.

Server suites (173), both shells' compression proofs and the listener-overhead proof are green in the clean clone on this head; the full wave follows below.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

Verification on the final head (d0e818f)

Clean clone, real install, core dist rebuilt from the branch: npm test 5192 pass, 0 fail, 2 skipped (Redis); Bun matrix 0 genuine failures; browser Chromium 981, Firefox 971, WebKit 981 passed, 0 failed; e2e 149 pass, 0 fail with the website up for the form files; real-app byte diff of 119 routes against main identical apart from clocks, a pid, the website fingerprint and the rewritten SSR docs page. CI: all ten jobs green on this head.

t added 6 commits October 28, 2026 01:45
capture() saved the buffer, frames and start-tag state but not the open
text-only elements, the deferred-region depth or the ancestor chain. A
throw inside a component's template walk (a rejected promise hole, a
repeat() callback) abandons the frames and text-only elements it opened,
and per-component error isolation (#469) catches it and keeps walking.
The stale state then leaked into the rest of the page: a leftover
inert entry or defer count left every later component as a bare tag,
and a leftover ancestor made a sibling's closest() resolve to the
component that failed. The renderer before the one-pass walk had no
shared state, so it rendered both correctly.
A start tag with no closing > (an unsafeHTML excerpt truncated inside a
registered element's attributes) still produced an OPEN op. The walker
moves the page built so far aside at a registered OPEN and only puts it
back at the matching >, so with no > everything rendered before the tag
was lost. The tokenizer now drops the OPEN of a start tag still open at
the end of its input, leaving those bytes as text, which is what its
comment already promised and what a browser does with them.
The new after-attribute-name state ended the start tag with the
attribute-exit rule, so `<script async >` stopped entering raw text and
its holes were HTML-escaped, turning `1 < 2 && 3` into `&lt;` and `&amp;`
inside the script. The renderer this replaces was back in its in-tag
state after that whitespace, and that exit entered raw text, which the
compiler's own comment says it keeps exactly.
A <webjs-suspense> with no streaming ctx renders its children inline and
drops the fallback, but the walker still captured the fallback with
expansion on, instantiating its components and running their renders
(and any data fetch in an async render) for bytes it then discarded.
The fallback is captured only when a ctx will show it now.
Every finish() call passed whether a matching end tag was found, and
the flag reached only errorState(), which discarded it with `void`.
The token test ignored quality values, which the brotli-first order used
to hide. The small-body gzip preference exposed it: `gzip;q=0, br` with
a body under 32 KB got gzip, a coding the client explicitly refused.
A coding listed with q=0 now counts as not accepted (RFC 9110), which
also stops `br;q=0, gzip` from getting brotli on a large body.

@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.

Reviewed at high effort. The one-pass structure looks sound, and the golden corpus plus the Bun parity script are a good gate. I found three correctness bugs that I reproduced against this head (main renders all three correctly), one regression against the tokenizer's own stated contract, and a few cleanups. All of them are inline.

Fixes and regression tests are on fix/review-1647, based on this head as six commits:

  • restore inert / defer / ancestors in capture()
  • drop the OPEN of a start tag cut off by end of input
  • after-name > enters raw text again
  • capture a boundary fallback only when a ctx shows it
  • drop the dead closed flag
  • refuse q=0 codings in negotiateEncoding

New file packages/core/test/rendering/ssr-walker-isolation.test.js: 6 cases, every one red on this head, green on Node and under bun test. There is also a q=0 case in listener-core.test.js and a live gzip;q=0, br assertion in test/bun/compression.mjs (passes on Bun). The golden corpus is unchanged at 94/94, the Bun golden parity holds at 93, and all core node tests pass.

Not inline, because it is outside the diff: comments still name the removed injectDSD walk and substituteSlotsInRender (slot.js:77, registry.js:34, component/lifecycle.js:641, test/bun/slot-ssr-parity.mjs:8, ssr-comment-not-an-element.test.js:237,457).

* @param {() => Promise<void>} fn
* @returns {Promise<string>}
*/
async capture(fn) {

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.

Bug: a throw inside capture() leaks walker state into the rest of the page. capture() saves buf / frames / tagState / controller, but inert, defer and ancestors are shared and are not restored. A throw inside a component's template walk (a rejected promise hole, a repeat() callback, a computed signal) abandons the frames and text-only elements it opened, and finishComponent catches it (#469) and keeps walking.

  • render() { return html<textarea>${Promise.reject(err)}</textarea> } leaves inert = ['textarea'], so every later component on the page renders as a bare tag (<good-el></good-el>, no DSD and no hydrate marker). On main the sibling renders.
  • The same throw inside <iso-inner> leaves the inner instance on ancestors. The this.ancestors.pop() at L628 then pops the inner one instead of the failed host, so the failed host stays an ancestor for good and a later sibling's closest('bad-el') resolves to it.
  • The same throw inside a <webjs-suspense> under a streaming ctx leaves defer > 0, so nothing after it expands.

Fix: snapshot inert (copy), defer and ancestors.length alongside the rest and restore them in the finally.

}
// An unterminated tag, comment or close tag: the bytes stay text, as a
// browser treats them.
if (pendingOpen) pendingOpen.tag = currentTag;

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.

Bug: a start tag cut off by the end of its input drops everything rendered before it. The OPEN op survives with no TAG_END. For a registered tag, Walker.open() moves this.buf aside into tagState.outer and only splices it back at the >, which never arrives. Repro: html

SITE

${unsafeHTML('excerpt <good-el class="x')}

END`` renders <good-el class="x</p><footer>END</footer>, so the header and the excerpt are gone. A truncated CMS excerpt is a realistic way to hit this. The comment above says the bytes stay text. Dropping the OPEN when `state` is still a start-tag state at EOF makes that true.

chunk += c;
if (c === '=') state = 'after-eq';
else if (WS.test(c)) { /* keep waiting */ }
else if (c === '>') finishStartTag(false);

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.

Regression: <script async > holes are now HTML-escaped. Whitespace after a valueless attribute used to put the old machine back in in-tag, and that exit entered raw text. after-name now exits through the attribute rule, so <script async >var a = ${'1 < 2 && 3'}</script> emits 1 &lt; 2 &amp;&amp; 3, which breaks the script. The finishStartTag doc says the old exits are kept exactly, and this case is not in the PR's list of deliberate changes.

Suggested change
else if (c === '>') finishStartTag(false);
else if (c === '>') finishStartTag(true);

if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) return 'gzip';
const br = /(?:^|,\s*)br(?:;|,|$)/.test(accept);
const gzip = /(?:^|,\s*)gzip(?:;|,|$)/.test(accept);
if (br && gzip && bodyBytes !== undefined && bodyBytes < BROTLI_MIN_BYTES) return 'gzip';

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.

Bug: the small-body preference picks gzip even when the client refused it with q=0. The token regexes ignore quality values. Brotli-first ordering used to hide that. Now negotiateEncoding('gzip;q=0, br', 2400) returns 'gzip', which is a coding the client explicitly said it does not accept. Parse the q-value and treat q=0 as not accepted. That also fixes the existing br;q=0, gzip case, which picks br today.

Comment thread packages/core/src/render-server/walk.js Outdated
// `<webjs-suspense .fallback=${html`...`}>` (#471) is the boundary's
// placeholder, rendered now so the walker can emit it at the tag.
if (tag === 'webjs-suspense' && name === 'fallback') {
if (this.expand && ts) ts.fallback = await this.capture(() => this.value(val));

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.

Wasted work: a blocking boundary still expands the fallback it then drops. With no ctx, finishSuspense renders the children inline and ignores frame.fallback. This capture still runs with expansion on, though, so every component in the fallback is instantiated and rendered, and an async render's data fetch runs too. The old renderer rendered the fallback without the DSD pass. Only a ctx ever shows the fallback.

Suggested change
if (this.expand && ts) ts.fallback = await this.capture(() => this.value(val));
if (this.expand && this.ctx && ts) ts.fallback = await this.capture(() => this.value(val));

// whitespace goes with the attribute, since every injected attribute
// carries its own leading space.
if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag);
this.buf = this.buf.replace(/\s+$/, '');

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.

Efficiency: when the bound <form> is not a frame-taking tag, this.buf is the whole page so far. replace(/\s+$/, '') here, plus the slice(0, ts.start) + ... at L558 and L561, copies the whole page buffer once per bound form or submitter. That is the same rope-materialising slice that e137c0c removed for frames. Trimming only the start-tag segment (from ts.start) would keep the cost bounded by the tag.

if (encoding && buf.byteLength >= MIN_COMPRESS_BYTES) {
const out = compressBufferSync(encoding, buf);
// The size is known now, so the small-body gzip preference applies (#1646).
const sized = negotiateEncoding(req.headers['accept-encoding'], buf.byteLength);

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.

Simplification: the encoding is negotiated twice, once before the peek (encoding) and again here (sized). withEncoding() then writes encoding and the next line overwrites it with sized. listener-bun.js has the same double write. Negotiating once after the peek, with the size when it is known, would keep one source of truth for the header.

console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
errorInner = '';
}
void closed;

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.

Dead parameter: closed goes from every finish(frame, closed) call through finishComponent to errorState, and its only use is void closed. It can be dropped from the whole chain.

t added 3 commits October 28, 2026 01:49
Review of #1647: the bound-form edits stripped the action attribute's trailing whitespace and spliced method, enctype and the identity into the start tag by slicing the whole page buffer, once per bound form or submitter outside a component. A form, button or input start tag is now built in its own buffer and joined back at its >, the same mechanism a frame-taking tag already uses, so the edits work on the start tag alone.

Refs #1646
Review of #1647: both shells wrote the negotiated coding, then overwrote it on the buffered path with the size-aware re-negotiation. The first negotiation now only decides whether a response is compressed (and is the coding a streamed body gets); the header is set once, on the path that sends the body.

Refs #1646
Review of #1647: four comments still described the two state machines or injectDSD as current behaviour.

Refs #1646
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Review response (review 5673703245)

All eight findings are fixed on this PR branch; nothing was split out.

# finding fix
1 capture() left the walker's open text-only elements, defer depth and ancestor chain behind when a component template threw 72870b9 restores all shared state; ssr-walker-isolation.test.js reproduces both symptoms (a later component left as a bare tag, a sibling's closest() finding the failed component)
2 a start tag with no > (a truncated unsafeHTML excerpt) dropped the page before it 87f2b27: the tokenizer drops the OPEN of a start tag still open at the end of its input, so its bytes stay text
3 negotiateEncoding ignored q=0, so the small-body gzip preference could pick a refused coding 94368e3: a coding listed with q=0 is never negotiated; unit case plus a live gzip;q=0, br check in test/bun/compression.mjs
4 whitespace before > after a valueless attribute (<script async >) escaped the script body 190737f keeps raw text there, as main did
5 a blocking boundary rendered its fallback's components and discarded them 4489494 renders the fallback only when a stream will show it
6 every bound form or submitter copied the whole page buffer 066b148 builds a form / button / input start tag in its own buffer and joins it back at its >
7 both shells negotiated twice and overwrote content-encoding 1e718d5 writes the header once, on the path that sends the body
8 the unused closed parameter threaded through the finish calls d510a4d removes it
(summary) comments outside the diff still named injectDSD / the two state machines as current 9b15da7 corrects the four that described current behaviour; the ones that are history (Until #1646, what injectDSD used to do) stay

Verification on the post-review head (9b15da7)

Clean clone, real install, core dist rebuilt from the branch:

check result
npm test 5199 pass, 0 fail, 2 skipped (Redis)
Bun matrix 0 genuine failures
browser x3 Chromium 981, Firefox 971, WebKit 981 passed, 0 failed
e2e (every file, website up for the form files) 149 pass, 0 fail
ssr-walker-isolation.test.js (the review's repros) 6 pass; all six fail on d0e818f
compression proofs, listener overhead pass on Node and Bun
golden byte parity on Bun, form-action parity on Bun 93 goldens identical; form-action parity OK
real-app byte diff against main, 119 routes every difference is a clock, a pid, the website fingerprint or the rewritten SSR docs page; the 15 bound forms across the apps are byte-identical
CI all ten jobs green

Engine ratio against React's renderToReadableStream (the call TanStack Start makes), interleaved on one host:

page Node Bun
page + 1 component 0.16 0.26
10 slotted components 0.50 0.67
2.5 KB page + component 0.18 0.55
blog-like, 61 instances 0.56 0.89

@vivek7405
vivek7405 merged commit 0573a8b into main Oct 27, 2026
10 of 11 checks passed
@vivek7405
vivek7405 deleted the perf/one-pass-ssr-renderer branch October 27, 2026 20:34
vivek7405 added a commit that referenced this pull request Oct 27, 2026
Ships the one-pass SSR renderer and the prototype-installed reactive
accessors in core (#1647), and in server the per-build memos and Node RSS
release after load (#1645) plus the small-body gzip preference and q=0
handling in compression (#1647).

Co-authored-by: t <t@t>
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.

perf(core): one-pass SSR renderer compiled per template

1 participant