Skip to content

Wireformat to importable opcodes: hello-world goes from 37.6 to 14.5 kB (brotli) - #21585

Draft
NullVoxPopuli-ai-agent wants to merge 28 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-importable-opcodes
Draft

Wireformat to importable opcodes: hello-world goes from 37.6 to 14.5 kB (brotli)#21585
NullVoxPopuli-ai-agent wants to merge 28 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-importable-opcodes

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Spike, not for merge. It stacks the eleven PRs below so you can build one branch and see the number. Review happens in the small PRs.

smoke-tests/v2-app-hello-world-template, pnpm build raw gzip brotli
main 134.4 kB 42.6 kB 37.6 kB
this branch 50.7 kB 16.4 kB 14.5 kB

See it in five minutes

  1. Check out this branch and run pnpm install && pnpm build.
  2. Run cd smoke-tests/v2-app-hello-world-template && pnpm build.
  3. Read the size of dist/assets/main-*.js. Serve dist/ and the page renders hi .

What changes

A template no longer ships as a JSON block that a browser compiler turns into VM words. The build compiles a strict template to VM words, and the output imports only the opcode handlers it uses. A bundler then drops every handler, helper, and keyword that no template in the app needs. The VM registration phase at module load is gone.

The compiled output for <template>hi </template>:

import { TEXT_OP } from '@glimmer/runtime/lib/compiled/opcodes/dom';
import { createTemplateFactory } from '@ember/template-factory/aot';

createTemplateFactory({
  id: '...',
  block: [[], [TEXT_OP], [[[297, 0, 1029], 0, [1], []]], [[0, 'hi ']]],
  moduleName: '(unknown template module)',
  isStrictMode: true,
});

The serialized template keeps its shape: id, block, moduleName, isStrictMode, scope. Only block changes.

The PRs, in order

Each PR chains on the one before it. Sizes are the hello world app after that PR, raw and gzip at the default level, from each PR's description. The table above uses gzip level 9 and brotli quality 11.

Compiler output (135.1 / 43.3 kB to 123.2 / 39.6 kB)

  1. Register VM opcode handlers on first use instead of at module load #21584 Register VM opcode handlers on first use. Each handler becomes a named export, and encodeOp registers it on first push, so a bundler can drop handlers nobody emits.
  2. Make wire format opcodes importable objects NullVoxPopuli-ai-agent/ember.js#3 Make wire format opcodes importable objects. Each sexp opcode becomes an object that carries its compile function, so a JSON block is no longer the only input.
  3. Add precompileModule, which prints opcodes as identifiers NullVoxPopuli-ai-agent/ember.js#4 Add precompileModule. It prints a template as JavaScript that references its opcodes by identifier, plus the list of imports the build tool must add.
  4. Patch babel-plugin-ember-template-compilation to import opcodes NullVoxPopuli-ai-agent/ember.js#5 Patch babel-plugin-ember-template-compilation to use precompileModule. By PR 11 the compilers bind every import themselves through jsutils.bindImport, so the patch is: call precompileModule, wrap the expression in its factory.

Runtime that loads on demand (to 75.5 / 23.9 kB)

  1. Compile stdlib routines on first use NullVoxPopuli-ai-agent/ember.js#6 Compile stdlib routines on first use instead of at VM creation.
  2. Let the rendering environment take hooks from the modules that own them NullVoxPopuli-ai-agent/ember.js#7 Let the rendering environment take its hooks from the modules that own them, so {{each}}, class normalization, and the runloop only load when used.
  3. Bind strict mode keywords to imports and fill resolver tables on demand NullVoxPopuli-ai-agent/ember.js#8 Bind strict mode keywords to imports and fill the resolver tables on demand. A strict app that never resolves by name does not load the classic resolver.

Trim the always-on VM (to 55.2 / 17.9 kB)

  1. Move attribute, resolution, and list code out of the always-on VM paths NullVoxPopuli-ai-agent/ember.js#9 Move attribute, resolution, and list code out of the always-on VM paths.
  2. Let an app leave the debug render tree out of its build NullVoxPopuli-ai-agent/ember.js#10 Let an app leave the debug render tree out of its build with VITE_NO_DEBUG_RENDER_TREE=true.
  3. Move debug tree, tree builder, resolver, and modifier code off the always-on paths NullVoxPopuli-ai-agent/ember.js#11 Move debug tree bookkeeping, tree builder extras, the resolver, definition caches, modifier scheduling, and iterator extras off the always-on paths.

Ahead of time (to 50.6 / 16.4 kB)

  1. Compile strict templates ahead of time NullVoxPopuli-ai-agent/ember.js#12 Compile strict templates ahead of time. The stdlib routines and strict templates ship as VM words, so no template compiler runs in the browser for them. Template keywords such as mut and {{outlet}} bind as imports in the AST, with JavaScript shadowing, so the keyword lists and built-in tables go away. Opcode handlers, stdlib routines, and sexp ops bind through the same jsutils.bindImport call. Loose templates and the runtime compilers bind the same way, so the built-in name tables load only for JSON blocks from addons published before this change.

What does not change

  • The numeric dispatch table and the int32 heap layout.
  • Loose mode templates. They keep their wire format opcodes as imported objects and resolve names at runtime.
  • The runtime template() compiler in the browser.

Status

pnpm test: 9484 tests, 17 skipped, 0 failures. pnpm lint and pnpm type-check:internals pass. The classic, Vite, and strict resolver smoke apps pass. CI is green on the fork for every PR in the chain. The plan and the numbers per step: https://claude.ai/code/artifact/153fb9f5-67b3-44e3-b2d9-d6f2f618f25b

🤖 Generated with Claude Code

https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81

NullVoxPopuli and others added 25 commits August 30, 2026 21:49
Each syscall handler in `runtime/lib/compiled/opcodes/` is now a named
export that carries its opcode number: `syscall(VM_X_OP, fn)` returns
`{ type, evaluate }` and registers nothing. The compile-time code in
`opcode-compiler` pushes the handler object instead of the bare number,
and `encodeOp` registers it in the dispatch table the first time a
template pushes it.

This removes `runtime/lib/bootstrap.ts`, whose eight side-effect imports
forced every handler into any bundle that used the VM. A bundler can now
drop a handler when no compile-time code that emits it survives
tree-shaking. The `/*#__PURE__*/` annotation on each `syscall(...)` call
is what lets the bundler drop it.

The numeric dispatch table and the int32 heap layout are unchanged. In
DEBUG, an opcode with no registered handler throws with the opcode number
instead of failing inside `unwrap`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Each statement and expression compiler in `opcode-compiler/lib/syntax/`
is now an exported op object, `{ id, compile }`, created with a
`/*#__PURE__*/` call. `@glimmer/opcode-compiler/ops` re-exports every op
under its `SexpOpcodes` name. A compiled template can put the op object
in the head position of a tuple, and `compileSexp` calls `head.compile`.
Because each op imports the runtime handlers it pushes, importing an op
pulls in only the handlers that op needs.

Templates that still ship a JSON block have numeric heads. The template
factory in `lib/template.ts` handles them: it fills a number-to-op table
from every op through `syntax/legacy.ts` and parses the block. A build
that emits op-headed blocks uses `lib/template-core.ts` through the new
`@ember/template-factory/modular` entry, so the legacy table and the
unused ops stay out of the bundle. `@glimmer/program` builds its default
template from `YieldOp` directly for the same reason.

Head comparisons in `helpers/resolution.ts` and the `Append` compiler go
through `headId`, which accepts either a number or an op object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`precompileModule` in `@glimmer/compiler` compiles a template the same
way `precompile` does, but prints the block as JavaScript whose tuple
heads are identifiers such as `__wf_Append`. It returns the expression
plus the list of identifiers to bind, each with the module and export
name to import (`@glimmer/opcode-compiler/ops`). A build tool adds the
imports and renames the identifiers. The template `id` is still hashed
from the JSON form, so ids do not change between the two formats.

`ember-template-compiler/minimal.ts` exports it, which is the entry the
babel plugin loads, and the build wiring in `broccoli/` passes it
through. `rollup.config.mjs` adds `@glimmer/opcode-compiler/ops` as an
exposed entry so an app can import it from ember-source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
This is a temporary `pnpm patch` until the change lands upstream. When
the template compiler exposes `precompileModule`, the plugin calls it,
binds each opcode identifier to an import from
`@glimmer/opcode-compiler/ops`, and emits `createTemplateFactory` from
`@ember/template-factory/modular`. Compilers without `precompileModule`
take the JSON path as before.

`babel-import-util` matches its placeholder nodes by identity, so the
plugin replaces every occurrence of an opcode identifier with a fresh
node from `i.import()` instead of renaming in place.

With this patch, ember-source's own templates and the smoke test apps
compile to op-headed blocks. Hello world smoke app, Vite production
build: 135.07 kB / 43.27 kB gzip before, 123.90 kB / 39.77 kB gzip after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The standard library used to compile all five routines eagerly when an
evaluation context was created. `StdAppend` references the component
invocation helpers and the content handlers, so every app shipped them
even when no template had a dynamic `{{value}}`.

A routine is now a `StdlibBuilder` object. `stdlibOperand` takes the
builder instead of a name, and `StdlibImpl.handle(builder)` compiles it
the first time a template asks for it. Because a routine compiles into
its own heap region, the encoder records a fixup for the operand and
patches in the handle after `commit`, once the asking program's region
is closed. Nested routines (`cautious-append` calls
`cautious-non-dynamic-append`) use the same fixup path.

`context.stdlib.main` still works. It compiles the root routine on first
access, which happens on the first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The Glimmer global context and the renderer used to import `get` and
`set` from metal, the Ember array iterator, proxy checks, and backburner
directly. A strict app that renders one component with `renderComponent`
shipped the whole object model and the run loop through those imports.

`-internals/glimmer/lib/hooks.ts` now holds slim defaults: tracked reads
and writes on plain objects, native arrays and iterables, and a run loop
with `actions`, `render`, and `destroy` queues flushed on a microtask.
The modules that own the richer behavior register their versions when
they load:

- `metal/lib/property_get.ts` and `property_set.ts`: `get` and `set`
- `metal/lib/tags.ts`: `tagForProperty`
- `@ember/array/index.ts`: `isEmberArray` and `objectAt`
- the proxy mixin: `isProxy`
- `@ember/runloop/index.ts`: backburner

Every entry in the global context delegates at call time, and listeners
attached to the default loop move to backburner on registration, so
import order does not matter. An app that imports any of those modules
behaves as before. The hello world smoke app drops from 114.1 kB to
94.7 kB raw (36.9 kB to 30.7 kB gzip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
In a strict template, `mut`, `readonly`, `unbound`, and the keywords the
AST transforms emit (`-each-in`, `-track-array`, `-in-el-null`, and so
on) compiled to `GetStrictKeyword` and resolved through the resolver's
built-in helper table. That table imported every keyword helper, and
through them the object model, so the strict render path shipped all of
it.

Three changes:

1. The module printer takes a `lexicalKeywords` map. A keyword in the map
   is printed as `GetLexicalSymbol` with a slot after the template's own
   locals, its import is added to `imports`, and `precompileModule` adds
   the slot to the `scope` function. Ember's wrapper supplies the map for
   strict templates, pointing at `@ember/-internals/template-keywords`.
2. The resolver's built-in tables start empty and `ensureBuiltins()`
   fills them. The legacy JSON factory, the new
   `@ember/template-factory/loose` factory, and the classic `Renderer`
   call it, because those are the paths that resolve by name.
   `precompileModule` now reports which factory the build tool must
   import, and the babel plugin patch uses it.
3. `EachInWrapper` moves to its own module, so the default iterator no
   longer pulls in the `-each-in` helper and its dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`NewTreeBuilder.setDynamicAttribute` was a class method, so every bundle
carried the dynamic attribute classes, property normalization, and URL
sanitization behind it. The logic is now `setDynamicAttribute()` in
`vm/attributes/dynamic.ts`, and the two handlers that set dynamic
attributes import it. A template with only static attributes drops it.

The default iterator checked for `EachInWrapper` and carried the object
and map iterators with it. `toIterator` now tries registered extensions
first, and the `-each-in` helper registers the each-in iterator when it
loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`encodeOp` switched on five numeric resolution opcodes and imported all
of `helpers/resolution.ts` to do so, which put the loose mode name
resolution code in every bundle. The ops that resolve by name now push a
`ResolutionHandler` object, and the encoder calls `head.resolve`. A
build with only strict templates drops the resolution code with the ops
that need it. `Local` and `TemplateLocal` stay numeric, since the
encoder handles them inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`enterList`, `registerItem`, and `exitList` were VM methods, so the VM
referenced `ListBlockOpcode` and every bundle carried the keyed list
diffing behind `{{#each}}`. They are functions in `vm/lists.ts` now, and
the list handlers import them. `enterItem` stays on the VM because
`ListBlockOpcode.sync` calls it, and moving it would create an import
cycle between `update.ts` and `lists.ts`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`ComponentElementOperations.flush` called `setDynamicAttribute` for
reference values, which kept the dynamic attribute classes, property
normalization, and URL sanitization in every component render. Callers
of `setAttribute` now pass an applier along with the reference, and
`flush` uses it. The `ComponentAttr` handler, the curly component
manager, and the classic attribute bindings all pass
`applyDynamicAttribute`, so a template-only component whose attributes
are all static drops that code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The Glimmer environment constructed `DebugRenderTreeImpl` itself when
the delegate enabled debug tooling, so the implementation was in every
bundle even though Ember turns it on from a runtime flag. The delegate
now supplies the tree, and `EmberEnvironmentDelegate` constructs it
behind an `@embroider/macros` condition:

    setConfig: { 'ember-source': { debugRenderTree: false } }

in an app's `buildMacros` config drops the implementation. Without the
config, behavior is unchanged: the tree exists when
`ENV._DEBUG_RENDER_TREE` is set, which the inspector does at runtime.

`@embroider/macros` moves to `dependencies` because published code now
imports it, and the test babel config evaluates the macros so the suite
runs. A type stub covers `@glint/template`, which the macros types
import and this repo does not depend on. The hello world smoke app opts
out to show the effect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The component and modifier handlers carried the code that feeds the
debug render tree inside `if (vm.env.debugRenderTree !== undefined)`
blocks: args capture, custom lifecycle nodes, destructor registration,
and two updating opcodes. That is about 5 kB of source in every bundle
even when the tree is left out.

Those blocks are now methods on `DebugRenderTreeImpl`
(`componentDidGetSelf`, `componentDidRenderLayout`, `modifierDidAdd`,
`remoteElementDidPush`), and the handlers call them through
`debugTree(vm.env)?.`. A build that opts out of the tree drops the
bookkeeping with it. The runtime only ever receives `DebugRenderTreeImpl`,
which is why `debugTree()` can narrow the host-facing type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`BaseRenderer.strict` constructed `ResolverImpl` up front, which kept
the resolver and `@ember/instrumentation` in every bundle. The strict
path now starts with `LazyResolver`, whose lookups return `null` until
`ensureBuiltins()` registers the real resolver. The paths that resolve by
name already call `ensureBuiltins()`, so behavior is unchanged for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…uilder

`NewTreeBuilder` carried `{{#in-element}}` support (`RemoteBlock` and the
remote push and pop), the block list behind `{{#each}}`, and the appends
for `{{{html}}}`, fragments, and nodes as class methods, so every bundle
kept them. They are functions now:

- `vm/remote-element.ts`: `pushRemoteElement`, `popRemoteElement`,
  `RemoteBlock`. Builders that customize the push (rehydration,
  serialization) implement the optional `__pushRemoteElement` hook, and
  the serializer reuses `defaultPushRemoteElement` after adding its
  marker.
- `vm/dynamic-content.ts`: `appendDynamicHTML`, `appendDynamicFragment`,
  `appendDynamicNode`.
- `vm/lists.ts`: `AppendingBlockList`, pushed directly by `enterList`.

`pushBlock` and `pushElement` become public for those functions. Every
tree builder in the repo extends `NewTreeBuilder`, which the functions
rely on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…sImpl

`ConstantsImpl.helper()`, `.modifier()`, and `.resolvedComponent()` exist
for name resolution: loose mode templates and `{{component "name"}}`.
As class methods they stayed in every bundle, and through
`getInternalHelperManager` they kept the custom helper manager too.

They are functions in `@glimmer/program/lib/definitions.ts` now, with
their caches keyed by the constants pool. `ResolutionTimeConstants` keeps
`component()` and `defaultTemplate`, which every render uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`api.ts` built `new CustomHelperManager(() => new FunctionHelperManager())`
at module load, so the custom helper manager and the args proxy reached
every bundle that looked up a component manager. The default is created
the first time a plain function is used as a helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`@ember/-internals/browser-environment` computes `isChrome`, `isFirefox`,
and the user agent on load. The renderer only needs `hasDOM`, so it
imports that module directly. The hello world smoke app turns off Vite's
module preload polyfill, which has nothing to do for a single script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The transaction held the modifier install and update queues and the
tracking code that flushes them, so every bundle carried modifier support
whether or not a template used a modifier. The transaction now offers
`schedule(hook, item)`: work queued under a hook runs once at commit with
everything queued under it. `vm/modifiers.ts` owns the install and update
hooks, and the modifier opcodes queue through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…loads

The default template iterator knew about Ember arrays and objects with a
`forEach` method. Both are Ember conventions, so `@ember/array` now
registers them as an iterator extension when it loads. The default
handles native arrays and native iterables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The five standard library routines and the default component template
were compiled in the browser on first use, which kept the encoder, the
label patcher, and the component invocation helpers in every bundle.

`bin/build-aot-stdlib.mjs` now compiles them in node and writes the
words to `opcode-builder/stdlib-data.ts`, with the handlers each routine
dispatches and the positions that hold another routine's handle.
`StdlibImpl.handle(routine)` registers the handlers and copies the words
into the heap, resolving fixups first so each routine keeps its own heap
region. A test compares the committed data with a fresh build, so a
change to a routine or an opcode number shows up as a failure that names
the generator.

The same machinery serves templates:

- `lib/aot/record.ts` is an encoder that records words instead of
  writing them to a heap. It notes which words hold constants, stdlib
  handles, and nested block programs, and resolves labels.
- `lib/aot/template.ts` loads a recorded template into a context: it
  interns the constants, patches the words, and returns the handle.
- `template-core.ts` takes its layouts as a parameter. `template-jit.ts`
  supplies the browser compiler; the AOT loader supplies its own.

The default template is the first AOT template, so a context no longer
needs the browser compiler to exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`precompileAot` in `@glimmer/opcode-compiler/lib/aot/precompile.ts`
compiles a strict template to VM words at build time. The output has the
shape `precompileModule` already produces (an expression plus the imports
to bind), so the babel plugin needs no change. Lexical scope values and
strict keywords become constants by identifier, evaluated on first use so
a template can reference a class declared later in its module.

Ember's `precompileModule` wrapper now emits AOT output for strict
templates and points the build tool at `@ember/template-factory/aot`.
Loose templates keep the modular JIT path, since they resolve names at
runtime. The build exposes the handler modules and the stdlib data as
entry points, because compiled templates import them directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
A strict template resolved `mut`, `{{outlet}}`, and the helpers behind
`{{#each}}` through a keyword list and a built-in table. The AST
transforms now bind each keyword to its import, the way `array` and `fn`
already work, and a JavaScript binding with the same name shadows the
keyword. The runtime compiler supplies the same values through its
keywords object.

The keyword list, the keyword slots in both printers, and the built-in
keyword tables are gone. Loose templates still resolve the names through
the resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
@NullVoxPopuli

NullVoxPopuli commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

📊 Size report

Tarball size — 1.2 MB → 1.2 MB

dist/dev   6%↑

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 2.1 MB / 493.5 kB 6%↑2.2 MB / 9%↑535.8 kB

dist/prod   7%↑

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 1.9 MB / 450.8 kB 7%↑2 MB / 9%↑492.7 kB

smoke-tests/v2-app-template/dist   -4.32%↓

File Before (Size / Brotli) After (Size / Brotli)
./assets/api-{hash}.js 290.8 kB / 78.1 kB -5.55%↓274.7 kB / -4.42%↓74.6 kB
./assets/main-{hash}.js 20.8 kB / 6.9 kB 6%↑22.1 kB / 7%↑7.4 kB
Total (Includes all files) 343.1 kB / 95.2 kB -4.32%↓328.3 kB / -3.11%↓92.2 kB

smoke-tests/v2-app-hello-world-template/dist   -62.2%↓

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 134.4 kB / 37.6 kB -62.3%↓50.6 kB / -60.7%↓14.8 kB
Total (Includes all files) 134.7 kB / 37.7 kB -62.2%↓50.9 kB / -60.5%↓14.9 kB

🤖 This report was automatically generated by wyvox/pkg-size

NullVoxPopuli and others added 2 commits August 31, 2026 13:28
The compilers printed placeholder identifiers and returned an import
list, and the babel plugin patch walked the expression to bind each one.
The build tool's `jsutils` already rides along in `options.meta`, so the
printers now call `jsutils.bindImport` directly and embed the returned
name. Keywords, opcode handlers, stdlib routines, and sexp ops all bind
through the same function, and the patch shrinks to: call
`precompileModule`, wrap the expression in its factory.

Without `jsutils` (direct API use, the stdlib generator), the import
list remains as a fallback. `bindImport` accepts a null shadow target
for generated references, since nothing in template scope can shadow
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The `-each-in` wrapper now iterates itself through a symbol method, and
`@ember/array` extends iteration through the one environment hooks
object, so the iterator extension list and the wrapper module go away.

Loose templates bind their built-ins at build time, the names that
already win over app helpers, so a compiled loose template resolves only
user helpers and components by name. The renderer no longer fills the
built-in tables, the loose factory no longer needs them, and the base
renderer starts with no resolver instead of a lazy delegate. The tables
stay for JSON blocks from published addons and for templates compiled in
the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Wireformat to importable opcodes: hello-world goes from 37.6 to 14.8 kB (brotli) Wireformat to importable opcodes: hello-world goes from 37.6 to 14.5 kB (brotli) Aug 31, 2026
The browser compilers now bind every keyword to a bare local name, which
loose and strict templates can both invoke, and the evaluators supply
the values. That takes `compile()`, the test harness, and runtime
`template()` off the built-in name tables. The tables and their fill
remain only behind `@ember/template-factory`, for JSON blocks that
addons published before importable opcodes.

`-resolve` and `-disallow-dynamic-resolution` bind like every other
keyword, and the `{{#each-in}}` iterator moves into the helper module
that owns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Benchmarks: main vs this PR

Both benchmark suites ran on one machine, with ember-source packed from main at bdb2580 and from this PR at bf3ea0e.

Summary:

  • js-framework-benchmark: CPU and memory results are within noise. The bundle is 27% smaller uncompressed and 26% smaller with brotli. First paint is 35 ms earlier.
  • rere-benchmark: five benches are slower by 8% to 18% in both rounds. The other ten are within noise.
  • Bug: an app that does not import @ember/runloop hangs after its first re-render. The fix is one line, see below. The js-framework-benchmark numbers for the PR include that fix.

Hang in MicrotaskLoop

The js-framework-benchmark app renders with renderComponent from @ember/renderer and never imports @ember/runloop, so it uses the new MicrotaskLoop from packages/@ember/-internals/glimmer/lib/hooks.ts. After the first click on "Create 1,000 rows", the page never returned to idle.

Cause: the begin listener calls renderer.rerender(), which calls scheduleOnceRender(), which calls schedule(). The microtask that started the pass cleared scheduled before it called run(), so every pass queues the next microtask. The render queue drains in the same pass, so the loop spins with empty queues and isValid() true, and the _RERENDER_LOOP_LIMIT guard never fires.

The rere-benchmark apps boot a full application, which registers backburner, so they do not take this path.

Fix (commit 9db785665a on the local branch nvp/fix-microtask-loop-reschedule):

@@ -167,7 +167,9 @@ class MicrotaskLoop implements RunloopHooks {
   }
 
   private schedule(): void {
-    if (this.scheduled) return;
+    // Work queued while a pass runs drains in that pass; `run` re-schedules
+    // whatever is left, so queuing another microtask here would spin forever.
+    if (this.scheduled || this.inLoop) return;
     this.scheduled = true;
     queueMicrotask(() => {
       this.scheduled = false;

run() already re-schedules when a queue is left non-empty after the pass, so nothing is lost.

js-framework-benchmark (keyed)

PR build: this PR plus the fix above, compiled with the babel-plugin-ember-template-compilation patch from this PR. Both apps are copies of frameworks/keyed/ember. CPU rows are the median of 15 runs, with the standard deviation in parentheses. Memory and size rows are single samples. The runner alternates main and PR per benchmark.

benchmark type main (median) PR #21585 (median) change
01_run1k cpu 87.3 ms (±4.1) 86.3 ms (±2.7) -1.1%
02_replace1k cpu 97.6 ms (±4.1) 100.3 ms (±3.9) +2.8%
03_update10th1k_x16 cpu 37.3 ms (±2.6) 38.2 ms (±2.9) +2.4%
04_select1k cpu 15.5 ms (±1.5) 15.9 ms (±1.2) +2.6%
05_swap1k cpu 49.7 ms (±4.1) 48.9 ms (±2.9) -1.6%
06_remove-one-1k cpu 33.2 ms (±2.2) 34.5 ms (±2.7) +3.9%
07_create10k cpu 784.5 ms (±25.5) 795.6 ms (±12.4) +1.4%
08_create1k-after1k_x2 cpu 104.3 ms (±4.3) 105.6 ms (±5.5) +1.2%
09_clear1k_x8 cpu 42.4 ms (±2.9) 37.6 ms (±3.2) -11.3%
21_ready-memory memory 5.38 MB (±n/a) 5.26 MB (±n/a) -2.3%
22_run-memory memory 11.93 MB (±n/a) 12.44 MB (±n/a) +4.3%
25_run-clear-memory memory 6.08 MB (±n/a) 5.82 MB (±n/a) -4.4%
41_size-uncompressed size 139.30 kB (±n/a) 101.40 kB (±n/a) -27.2%
42_size-compressed size 39.10 kB (±n/a) 29.00 kB (±n/a) -25.8%
43_first-paint size 253.20 ms (±n/a) 218.30 ms (±n/a) -13.8%

rere-benchmark

Two rounds per side, in the order main, PR, PR, main. Each round is 10 samples per bench at 8x CPU throttle, not headless. The change column compares the median of the two rounds. A row is flagged only when the change is above 5% and above the spread between rounds on both sides.

benchmark unit main p50 (round 1 / round 2) PR #21585 p50 (round 1 / round 2) change (median of rounds)
DB Monitor w/ chat simulation FPS 24.6 / 32.8 28.1 / 29.2 -0.2%
Incrementing Render Effect ms 2340.3 / 2296.8 2270.4 / 2333.3 -0.7%
1 item, 1k updates (async) ms 60.3 / 55.0 63.6 / 54.4 +2.4%
1 item, 1k updates ms 8.8 / 8.0 8.5 / 8.3 +0.3%
1 item, 100k updates (async) ms 1038.6 / 1022.8 1030.3 / 1043.7 +0.6%
1 item, 100k updates ms 39.2 / 39.3 43.4 / 45.6 +13.4% (worse)
1k items, 1 update each (sequentially, async) ms 969.6 / 952.8 973.2 / 1000.6 +2.7%
1k items, 1 update each (sequentially) ms 53.1 / 51.7 58.9 / 57.4 +10.9% (worse)
1k items 1 update on 5% (random, async) ms 120.1 / 122.9 126.2 / 120.4 +1.5%
1k items 1 update on 5% (random) ms 30.4 / 29.0 30.2 / 29.1 -0.2%
1k items 1 update on 25% (random, async) ms 314.0 / 317.5 343.9 / 338.4 +8.0% (worse)
1k items 1 update on 25% (random) ms 36.3 / 37.1 40.8 / 39.5 +9.5% (worse)
1 value, 1k consumers, 10k updates (bursts of 100) ms 2066.3 / 1921.4 2023.2 / 2215.7 +6.3%
1 value, 1k consumers, 10k updates (bursts of 1000) ms 232.7 / 227.0 282.4 / 260.8 +18.2% (worse)
1 value, 1k consumers, 10k updates (single burst) ms 63.6 / 58.4 58.7 / 61.9 -1.2%

The flagged rows are the synchronous update benches and the async 25% random bench. Those are slower in both rounds, so they look real, not noise. The fan-out bench with bursts of 1000 is the largest at 18%.

Setup

  • AMD Ryzen 9 7900X, Ubuntu, Google Chrome 152.0.7977.64, virtual display reported as 59 Hz.
  • js-framework-benchmark at krausest/js-framework-benchmark afe7c11, npm run bench -- --framework keyed/ember-main keyed/ember-pr21585.
  • rere-benchmark at NullVoxPopuli/rere-benchmark 5d76b77, pnpm use-tar-for ember <tgz> then pnpm bench --framework=ember --bench=all --cpu-throttle=8 --yes. The PR side has the babel plugin patch through pnpm.patchedDependencies.
  • Result files: rere runs 9 and 12 (main), 10 and 11 (PR) under results/public/results/.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

📊 Size report

Tarball size — 1.2 MB → 1.2 MB

dist/dev   7%↑

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 2.1 MB / 493.9 kB 7%↑2.2 MB / 9%↑537.6 kB
Show files (94 files)
File Before (Size / Brotli) After (Size / Brotli)
./packages/@ember/-internals/deprecations/index.js 6.5 kB / 1.7 kB -11.4%↓5.8 kB / -3.71%↓1.6 kB
./packages/@ember/-internals/runtime/lib/mixins/-proxy.js 4 kB / 1.3 kB 3%↑4.2 kB / 2%↑1.4 kB
./packages/@ember/-internals/template-keywords/index.js — 307 B / 173 B
./packages/@ember/application/instance.js 16 kB / 4.5 kB -1.45%↓15.8 kB / -1.35%↓4.4 kB
./packages/@ember/array/index.js 17.5 kB / 4.4 kB 2%↑17.8 kB / 2%↑4.5 kB
./packages/@ember/array/lib/iterator-extension.js — 1.2 kB / 422 B
./packages/@ember/array/proxy.js 10.9 kB / 2.7 kB -2.55%↓10.6 kB / -3.54%↓2.6 kB
./packages/@ember/object/promise-proxy-mixin.js 4.1 kB / 1.3 kB -9.96%↓3.7 kB / -9.88%↓1.2 kB
./packages/@ember/object/proxy.js 2.8 kB / 872 B -12.3%↓2.5 kB / -12.5%↓763 B
./packages/@ember/routing/index.js 18.9 kB / 4.5 kB 8%↑20.4 kB / 10%↑5 kB
./packages/@ember/runloop/index.js 23.5 kB / 5.1 kB 3%↑24.1 kB / 3%↑5.2 kB
./packages/@ember/template-compiler/lib/compile-options.js 5.7 kB / 1.9 kB 9%↑6.2 kB / 10%↑2.1 kB
./packages/@ember/template-compiler/lib/plugins/auto-import-builtins.js 1.1 kB / 500 B 74%↑2 kB / 37%↑686 B
./packages/@ember/template-compiler/lib/plugins/index.js 2.6 kB / 623 B -10.2%↓2.3 kB / -15.4%↓527 B
./packages/@ember/template-compiler/lib/plugins/transform-each-in-into-each.js 1.4 kB / 557 B 9%↑1.5 kB / 2%↑568 B
./packages/@ember/template-compiler/lib/plugins/transform-each-track-array.js 1.3 kB / 526 B 12%↑1.5 kB / 5%↑550 B
./packages/@ember/template-compiler/lib/plugins/transform-resolutions.js 236 B / 121 B 1,430%↑3.6 kB / 724%↑997 B
./packages/@ember/template-compiler/lib/plugins/utils.js 1.3 kB / 408 B 116%↑2.9 kB / 125%↑916 B
./packages/@ember/template-compiler/lib/template.js 8.9 kB / 2.4 kB 1%↑9 kB / 0.4%↑2.5 kB
./packages/@ember/template-factory/aot.js — 109 B / 88 B
./packages/@ember/template-factory/index.js 84 B / 82 B 435%↑449 B / 176%↑226 B
./packages/@ember/template-factory/loose.js — 661 B / 284 B
./packages/@ember/template-factory/modular.js — 86 B / 82 B
./packages/@glimmer/compiler/index.js — 35.8 kB / 6.3 kB
./packages/@glimmer/destroyable/index.js 7.5 kB / 1.7 kB -94.1%↓439 B / -88.7%↓191 B
./packages/@glimmer/opcode-compiler/index.js 1.1 kB / 374 B 35%↑1.5 kB / 36%↑507 B
./packages/@glimmer/opcode-compiler/lib/aot/template/index.js — 4.7 kB / 1.4 kB
./packages/@glimmer/opcode-compiler/lib/opcode-builder/stdlib-data/index.js — 6.7 kB / 1.3 kB
./packages/@glimmer/opcode-compiler/ops/index.js — 843 B / 363 B
./packages/@glimmer/program/index.js 7.5 kB / 1.7 kB -34.4%↓4.9 kB / -14.1%↓1.4 kB
./packages/@glimmer/runtime/index.js 2.1 kB / 733 B 16%↑2.4 kB / 10%↑803 B
./packages/@glimmer/runtime/lib/compiled/opcodes/component/index.js — 2 kB / 571 B
./packages/@glimmer/runtime/lib/compiled/opcodes/content/index.js — 968 B / 280 B
./packages/@glimmer/runtime/lib/compiled/opcodes/debugger/index.js — 399 B / 156 B
./packages/@glimmer/runtime/lib/compiled/opcodes/dom/index.js — 1.2 kB / 352 B
./packages/@glimmer/runtime/lib/compiled/opcodes/expressions/index.js — 1.3 kB / 370 B
./packages/@glimmer/runtime/lib/compiled/opcodes/lists/index.js — 649 B / 202 B
./packages/@glimmer/runtime/lib/compiled/opcodes/vm/index.js — 1.2 kB / 411 B
./packages/@glimmer/syntax/index.js — 5.7 kB / 1.7 kB
./packages/@glimmer/wire-format/index.js 1.3 kB / 490 B -7.83%↓1.2 kB / -10.4%↓439 B
./packages/ember-template-compiler/index.js 4.1 kB / 1.4 kB 361%↑18.7 kB / 287%↑5.4 kB
./packages/@glimmer/runtime/lib/compiled/opcodes/component/index.js — 1.9 kB / 548 B
./packages/@glimmer/runtime/lib/compiled/opcodes/content/index.js — 974 B / 280 B
./packages/@glimmer/runtime/lib/compiled/opcodes/debugger/index.js — 399 B / 156 B
./packages/@glimmer/runtime/lib/compiled/opcodes/dom/index.js — 1.2 kB / 363 B
./packages/@glimmer/runtime/lib/compiled/opcodes/expressions/index.js — 1.3 kB / 369 B
./packages/@glimmer/runtime/lib/compiled/opcodes/lists/index.js — 542 B / 183 B
./packages/@glimmer/runtime/lib/compiled/opcodes/vm/index.js — 1.2 kB / 398 B
./packages/@glimmer/syntax/index.js — 5.7 kB / 1.7 kB
./packages/@glimmer/wire-format/index.js 1.3 kB / 490 B -7.83%↓1.2 kB / -10.4%↓439 B
./packages/ember-template-compiler/index.js 3.2 kB / 1.1 kB 462%↑17.8 kB / 363%↑5.1 kB
./packages/shared-chunks/-disallow-dynamic-resolution-CAGyG7L2.js — 19.4 kB / 4.8 kB
./packages/shared-chunks/api-{hash}.js 5.9 kB / 1.5 kB -31.8%↓4.1 kB / -18.9%↓1.2 kB
./packages/shared-chunks/arguments-{hash}.js 58 kB / 11.3 kB -78.9%↓12.3 kB / -77.3%↓2.6 kB
./packages/shared-chunks/bounds-BNXIYIt6.js — 1.3 kB / 395 B
./packages/shared-chunks/builtins-CQDt2Feo.js — 3.2 kB / 1.2 kB
./packages/shared-chunks/builtins-registry-BPogB4Jt.js — 1.2 kB / 437 B
./packages/shared-chunks/compilable-template-DKGR5d1F.js — 7.6 kB / 2.1 kB
./packages/shared-chunks/compiler-{hash}.js 360.1 kB / 69 kB -78.3%↓78.3 kB / -78%↓15.2 kB
./packages/shared-chunks/component-OqEkkh0y.js — 26.1 kB / 5.5 kB
./packages/shared-chunks/components-DNv0XxkO.js — 30 kB / 7.2 kB
./packages/shared-chunks/content-DIGgjBgv.js — 6 kB / 1.5 kB
./packages/shared-chunks/curly-{hash}.js 17.5 kB / 4.4 kB 0.7%↑17.6 kB / -0.64%↓4.3 kB
./packages/shared-chunks/curried-value-Cby2_e8z.js — 2 kB / 644 B
./packages/shared-chunks/debugger-BHMur4C5.js — 2.3 kB / 840 B
./packages/shared-chunks/dom-C-NPbwIg.js — 20.8 kB / 4.9 kB
./packages/shared-chunks/dynamic-Bh8epaqt.js — 13.3 kB / 3.3 kB
./packages/shared-chunks/element-builder-kcEYISTf.js — 7 kB / 1.7 kB
./packages/shared-chunks/expressions-B8Qtrid1.js — 10 kB / 2.4 kB
./packages/shared-chunks/fn-C2kYJ8T4.js — 2.8 kB / 883 B
./packages/shared-chunks/get-Bx33C_0v.js 1.3 kB / 465 B —
./packages/shared-chunks/hash-CURcVjJu.js 1.7 kB / 623 B —
./packages/shared-chunks/hooks-BTOg5nk1.js — 5 kB / 1.5 kB
./packages/shared-chunks/immediate-C9gpHnOE.js — 1 kB / 334 B
./packages/shared-chunks/index-{hash}.js 59.8 kB / 12.1 kB -74.2%↓15.4 kB / -62.3%↓4.6 kB
./packages/shared-chunks/iterator-DrxpOH4x.js — 3.1 kB / 842 B
./packages/shared-chunks/keywords-HXBSM-Px.js — 293 kB / 57.4 kB
./packages/shared-chunks/legacy-BCMrwPGN.js — 533 B / 281 B
./packages/shared-chunks/lists-DDemknEh.js — 3.5 kB / 1.2 kB
./packages/shared-chunks/modular-2VhZJpJe.js — 2 kB / 703 B
./packages/shared-chunks/normalize-{hash}.js 2.2 kB / 617 B -58.4%↓935 B / -54.6%↓280 B
./packages/shared-chunks/object-at-Bps-ulVR.js — 167 B / 95 B
./packages/shared-chunks/opcodes-{hash}.js 849 B / 358 B 752%↑7.2 kB / 408%↑1.8 kB
./packages/shared-chunks/ops-Dcn8fVIZ.js — 26.7 kB / 5.1 kB
./packages/shared-chunks/outlet-{hash}.js 3.9 kB / 1.3 kB 403%↑19.9 kB / 300%↑5.3 kB
./packages/shared-chunks/program-{hash}.js 5 kB / 1.4 kB -65.4%↓1.7 kB / -54.7%↓641 B
./packages/shared-chunks/property_get-{hash}.js 3.6 kB / 1.2 kB 8%↑3.8 kB / 7%↑1.3 kB
./packages/shared-chunks/property_set-{hash}.js 3 kB / 984 B 5%↑3.1 kB / 6%↑1 kB
./packages/shared-chunks/props-fiqxqhAH.js 2.3 kB / 735 B —
./packages/shared-chunks/remote-element-ngm0i_Qt.js — 3.1 kB / 1 kB
./packages/shared-chunks/render-{hash}.js 52.4 kB / 11.3 kB -45.2%↓28.7 kB / -40.1%↓6.8 kB
./packages/shared-chunks/resolution-DS8ldtWL.js — 185 B / 108 B
./packages/shared-chunks/resolver-CzSUdPO3.js — 4.2 kB / 1.1 kB
./packages/shared-chunks/setup-{hash}.js 28.8 kB / 7.7 kB -36.2%↓18.4 kB / -33.8%↓5.1 kB
./packages/shared-chunks/syscall-ops-CkPT1Kfx.js 6.4 kB / 1.5 kB —
./packages/shared-chunks/template-{hash}.js 497 B / 205 B 34%↑668 B / 58%↑324 B
./packages/shared-chunks/textarea-{hash}.js 12 kB / 3.1 kB 21%↑14.6 kB / 20%↑3.7 kB
./packages/shared-chunks/unwrap-template-Cui5CH7t.js — 260 B / 167 B
./packages/shared-chunks/update-C-UPvQUQ.js — 8.5 kB / 2.2 kB
./packages/shared-chunks/vm-B-Fr9fbF.js — 8.9 kB / 2.2 kB

smoke-tests/v2-app-template/dist   -5.25%↓

File Before (Size / Brotli) After (Size / Brotli)
./assets/api-{hash}.js 291.4 kB / 78.2 kB -6.91%↓271.2 kB / -5.9%↓73.5 kB
./assets/main-{hash}.js 20.8 kB / 6.9 kB 6%↑22.1 kB / 7%↑7.4 kB
./assets/modules-{hash}.js 30.1 kB / 9.3 kB 3%↑30.9 kB / 3%↑9.7 kB
Total (Includes all files) 344.2 kB / 95.3 kB -5.25%↓326.1 kB / -3.99%↓91.5 kB

smoke-tests/v2-app-hello-world-template/dist   -62.3%↓

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 134.9 kB / 37.6 kB -62.4%↓50.7 kB / -60.6%↓14.8 kB
Total (Includes all files) 135.2 kB / 37.7 kB -62.3%↓51 kB / -60.4%↓14.9 kB

🤖 This report was automatically generated by wyvox/pkg-size

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.

2 participants