Repository navigation
perf(core): one-pass SSR renderer compiled per template - #1647
Conversation
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.
|
Preview for Next: fix the listed keys in the compose file |
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
Verification on the final head (be202ca)Run in a clean clone with a real
A CPU profile of the one-component page after the change (60k renders): the component constructor ( Engine ratios on this host, interleaved in one process (lower is better, 1.0 is parity with React):
Before this PR the ten-component page sat at 1.46 (Node) and 2.53 (Bun) against |
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
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
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)
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
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. |
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
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:
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 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. |
Verification on the final head (d0e818f)Clean clone, real install, core dist rebuilt from the branch: |
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 `<` and `&` 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
left a comment
There was a problem hiding this comment.
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/ancestorsincapture() - 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
closedflag - refuse
q=0codings innegotiateEncoding
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) { |
There was a problem hiding this comment.
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>}leavesinert = ['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 onancestors. Thethis.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'sclosest('bad-el')resolves to it. - The same throw inside a
<webjs-suspense>under a streaming ctx leavesdefer > 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; |
There was a problem hiding this comment.
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
${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); |
There was a problem hiding this comment.
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 < 2 && 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.
| 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'; |
There was a problem hiding this comment.
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.
| // `<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)); |
There was a problem hiding this comment.
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.
| 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+$/, ''); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
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 response (review 5673703245)All eight findings are fixed on this PR branch; nothing was split out.
Verification on the post-review head (9b15da7)Clean clone, real install, core dist rebuilt from the branch:
Engine ratio against React's
|
Closes #1646
What
The server renderer made three passes over every render:
renderTemplatewalked each template character by character on every call,inertRangesre-tokenised the output, andinjectDSDregex-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.jstokenises a template ONCE perTemplateStringsArrayidentity 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.jsinterprets the ops with a buffered or a streaming sink, andexpand.jsinstantiates 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.fallbackasdata-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
pathToFileURLis 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.
renderToStringrenderToStringrenderToStringrenderToReadableStreamLower 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 norender-serverframe that scans rendered output.Deliberate behaviour changes (each a golden update in its own commit)
closest(); the old walk omitted the shadow host from its light children's ancestor chain, which does not match the DOM.<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.=(title= ${x},href = ${x}) binds; the old machine dropped it.<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
packages/core/test/rendering/ssr-golden/), green after it apart from the two documented diffs.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.jsrewritten for the compile cache (bypassing it fails the count assertion). Everyrender-servermodule is at 100 percent line coverage.test/bun/ssr-one-pass-parity.mjsrenders the whole corpus under either runtime and holds it to the fixtures, with a CI step.Doc surfaces
packages/core/AGENTS.md: therender-server.js,webjs-suspense.jsandcomponent.jsrows and the slot invariant describe the one-pass structure and the prototype accessors.packages/server/AGENTS.mdandreferences/runtime.md: the small-body gzip preference.website/app/docs/ssr/page.ts: the pipeline lists component expansion as part of the render walk.AGENTS.md, skill references, scaffold: N/A, no public API changes.