Materialize static template subtrees in one step - #21570
Open
NullVoxPopuli wants to merge 1 commit into
Open
Conversation
Contributor
📊 Size reportTarball size — dist/dev 0.5%↑
dist/prod 0.6%↑
smoke-tests/v2-app-template/dist 0.9%↑
smoke-tests/v2-app-hello-world-template/dist 2%↑
🤖 This report was automatically generated by wyvox/pkg-size |
NullVoxPopuli
force-pushed
the
nvp/cloneable-static-subtrees
branch
from
August 17, 2026 19:57
1320427 to
4da33f6
Compare
A run of statements that only builds static structure -- plus holes where its dynamic values go -- is described once and materialized by cloning a cached node, instead of compiling to one opcode per element, static attribute and static text node. Measured on a 1000-row krausest create before this change: of ~37ms of rendering JS, ~14.6ms is that static structure, ~7.4ms of it in DOM calls (8 createElement, 6 setAttribute, 13 insertBefore per row -- 34 DOM calls per row, 340k for 10k rows). The pre-pass runs in `compileStatements`, so it needs no wire-format change and works on already-published templates. A run is extracted only when it is an element-rooted subtree of at least two elements whose holes are dynamic attributes or last-child dynamic content. Components, blocks, splattributes, modifiers, comments, trusted HTML and mid-child content holes all end a run; bailing is always safe, since the caller then compiles the statements the ordinary way. Coverage measured over 766 of this repo's own test templates: 40% of static structure sits in runs of two or more elements, and both benchmark rows (krausest and dbmon) are a single 8-element run covering all of theirs. Two paths are emitted for each run, chosen at runtime: - when the tree builder can clone, the subtree is materialized in one step and the dynamic values are filled in at their holes - otherwise the run's original statements execute, in their original order The second path is not a nicety. Rehydration matches against server-rendered nodes and the SSR serializer interleaves block markers with construction, so both need each step in order: filling holes after the fact sets attributes after their element was flushed, and looks for a dynamic block's markers after its enclosing elements are already closed. An earlier attempt to serve both paths from a descriptor walk failed 16 rehydration tests for exactly that reason, leaving stray `%-b:0%` markers. Cloning also keeps namespaces correct for free -- the cached node is built with the ordinary DOM operations, in context -- which a serialized HTML skeleton would not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NullVoxPopuli
force-pushed
the
nvp/cloneable-static-subtrees
branch
from
August 17, 2026 20:45
4da33f6 to
ca16550
Compare
Contributor
Author
NullVoxPopuli
marked this pull request as ready for review
August 18, 2026 13:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Note
Written by Claude Opus 5 (
claude-opus-5) driving Claude Code, from @NullVoxPopuli's direction. All numbers come from runs on one machine.Static template structure is now built in one step, not one opcode per element, attribute and text node. 34 DOM calls per row become 10.
Numbers
pnpm benchagainstmain, 8x throttle, n=20. Overall duration-527ms, p=0.0056.render1000Items1Endrender1000Items2Endrender10000Items1Endrender10000Items2Endrender1000Items3EndupdateEvery10thItem2EndAll five render phases improved, and the pattern replicated across two runs. The one regression is a single phase inside this harness's spurious-hit rate. It needs a replication run.
How it works
The pre-pass runs in
compileStatements. No wire-format change, so published templates benefit.A run must be an element-rooted subtree of 2 or more elements. Its holes must be dynamic attributes or last-child dynamic content. Components, blocks, splattributes, modifiers, comments and trusted HTML end a run. Bailing is safe: the caller then compiles the statements the ordinary way.
Each run emits 2 paths:
Path 2 is not optional. Rehydration matches server-rendered nodes, and the SSR serializer interleaves block markers. Both need each step in order. An earlier single-path version failed 16 rehydration tests and left stray
%-b:0%markers.Cloning keeps namespaces correct at no cost, because the cached node is built with the ordinary DOM operations in context.
Why structure and not the rest
Rendering JS for a 1000-row create is about 37ms. Static structure is about 14.6ms of that. A hand-written clone of the same row costs about 4ms. The other 22ms is per-hole reactive machinery with no single hot spot, and this PR does not touch it.
Measured and rejected first, so nobody repeats them:
replaceChildrenfor clears. Saves 6ms of 32ms, not the 83% a profile implied.Trackers. Already reverted in39c062e72d.Profiler self-time on
insertBeforeis Blink style invalidation charged to the calling frame. It is not insertion cost.Coverage
Measured over 766 test templates in this repo: 40% of static structure sits in runs of 2 or more elements. 77% of runs hold one element, which sets the threshold at 2. Both benchmark rows are one 8-element run.
Cost
Every app pays for this, including apps that never hit a qualifying run.
@glimmer/opcode-compilerships to the browser, so the extractor is in the app bundle.v2-app-hello-world-templatev2-app-templatedist/prodtotal+2% on a hello-world app is the main thing to weigh against a 5-8% render win. A build-time flag would let Rollup drop both the extractor and the runtime path for apps that do not opt in. Say the word and I will add one.
Size
446 lines of production code, 100 of tests, 5 deleted. Nothing existing was replaced. This adds a compile-time extractor, 3 opcodes and their runtime, so there is no old path to remove. Three candidate consolidations were checked; two do not work, and the one that does (merging the 4 dynamic-attribute opcodes into 2, about 30 lines) is unrelated to this change and belongs in its own PR.
Tests
StaticTreeSuiteasserts the mechanism, not just the output, using the step log:rere-benchmark
No regression on any of the 15 benches. 6 interleaved rounds, alternating order, 10 samples per bench per round, 8x throttle. Per-round medians compared within the round, then a sign test across rounds.
One bench swept all 6 rounds:
1 item, 1k updates, -6.8%, p=0.031. Treat it as noise. It is the smallest bench in the set (6.40ms to 6.00ms), and this PR touches construction only, so a win on a pure update bench has no mechanism behind it.Nothing else reached p<0.10 with an effect of 3% or more. The one candidate regression at n=4 (
1k items 1 update on 5% (random), +3.1%) fell to 2/6 rounds at n=6.Checks
pnpm test:wip: 9,454 tests, 0 failures, 17 skippedpnpm type-check:internals,prettier,eslint: clean