Skip to content

Speed up loader transpilation ~10x via hand-rolled AMD wrapper (CS-10977)#4573

Open
habdelra wants to merge 14 commits intomainfrom
cs-10977-make-transpilation-in-loader-more-efficient
Open

Speed up loader transpilation ~10x via hand-rolled AMD wrapper (CS-10977)#4573
habdelra wants to merge 14 commits intomainfrom
cs-10977-make-transpilation-in-loader-more-efficient

Conversation

@habdelra
Copy link
Copy Markdown
Contributor

@habdelra habdelra commented Apr 29, 2026

Summary

Replace the loader's babel-based ES → AMD wrapping pass with a hand-rolled acorn + magic-string transpiler. ~9–13× faster than babel on real card sources, no new native deps.

Why

Indexer slowness is partly driven by the transformAsync(... TransformModulesAmdPlugin ...) call that runs every time Loader.fetchModule pulls a JS module. The realm-server has already lowered all the heavy syntax (TS, decorators, glimmer templates, scoped CSS) by the time the loader sees the source — so the loader's babel pass is doing nothing but ES → AMD wrapping, and paying the full price of babel's plugin pipeline + AST visitors + code generation.

What changed

  • New packages/runtime-common/amd-transpile.ts — acorn parse + magic-string string-edits, two-pass: first collects every ImportDeclaration binding into a name → dep-arg-access map; second emits the define(moduleId, deps, factory) wrapper, transforms exports, and runs a scope-aware identifier-rewrite walk that replaces every non-shadowed source-code reference to an imported name with _dep.name. Lookup happens at use time, so circular deps and forward references work.
  • packages/runtime-common/loader.tsLoader.fetchModule now calls transpileAmd instead of transformAsync + TransformModulesAmdPlugin. eval(src) regains a //# sourceURL=<moduleId> hint so stack traces name the original module.
  • New deps in runtime-common: acorn, magic-string (both already transitive in the workspace; added to the catalog).
  • Tests live with the code that runs in their context — both moved to packages/host/tests/unit/ (the loader runs in the browser, not in Node):
    • amd-transpile-test.ts — 39 tests covering imports, exports, re-exports, export *, import.meta, live-binding, circular-dep evaluation order, TDZ-safe default exports, destructured exports, parenthesised default expressions, IIFE defaults, __default$N collision avoidance, identifier shadowing, for-loop scope tracking, shorthand-property of imports, computed-key destructuring.
    • loader-retry-test.ts — 11 tests for fetchWithTransientRetry (was previously a runtime-common shared file wired through realm-server's qunit harness; moved alongside).

Benchmarks

Tested against post-realm-server-transpile output of real card modules — exactly what the loader's transpile pass sees today. 50 iterations + 5 warmup, median wall time. Latest run on the current commit (paren-fix + scope-aware rewrite + getter-based exports + TDZ-safe defaults):

Fixture Bytes babel current hand-rolled (this PR) speedup
enum.js 10 KB 17.1 ms 1.7 ms 10.3×
skill-set.js 71 KB 35.6 ms 4.1 ms 8.7×
spec.js 61 KB 40.9 ms 4.6 ms 8.8×
card-api.js 118 KB 241 ms 19.1 ms 12.6×

Parse-only floors for context (we already pay the parse cost):

Fixture acorn parse es-module-lexer parse
enum.js 0.49 ms 0.07 ms (×7 over acorn)
card-api.js 11.5 ms 1.7 ms (×7 over acorn)

We use acorn because the scope-aware identifier-rewrite walk needs a full AST; es-module-lexer only exposes import/export byte ranges. SWC native (also benchmarked earlier) lands at 6–12× over babel — comparable to what we get here, but adds a ~30 MB native binary across platforms.

Disabling sourcemaps on the babel pipeline gives only 1.0–1.2× — sourcemap encoding is not the bottleneck.

Live-binding semantics

Preserved across module boundaries via three mechanisms:

  • Source-code references to imports — the identifier-rewrite walk replaces every non-shadowed source reference to an imported name with _dep.name. Lookup happens at use-time, so by the time card code calls field(...) from inside a method, the dep has finished evaluating and the export is populated. This is what makes circular deps work (e.g. card-api.gtscontains-many-component.gts).
  • Re-exports of imported names (export { x } from 'foo', export *, export { localImported }) — getter that reads through the dep arg every time.
  • Mutable local exports (export let X / export var X) — getter that reads the local binding at access time, so mutations made by exported functions (export function inc() { X++ }) propagate to importers.

export default <expression> strips just the export default keyword (15 chars) and appends ) before any trailing ;, leaving any source-level parens intact (so export default (foo); becomes var __default$0 = ((foo)); — valid, parens harmless). The _exports.default setter is deferred to the end of the body — TDZ-safe and avoids magic-string overlap with the identifier-rewrite walk. The __default$N name is collision-checked against every top-level declared name in the source.

Validated

  • 39/39 unit tests pass (host) — including regression tests for: circular-dep evaluation order, export let mutated by an exported function, export default <imported_name>, export default (paren-expr) and IIFE defaults, destructured export const { a, b } = obj, computed-key destructuring, __default$N collision avoidance, identifier shadowing, for-let loop variable shadowing imports, for-in var hoisting, shorthand-property of imported names.
  • Identical dep counts (52/9/17/43) and identical export name sets (68/6/1/7) vs. babel's output on real card fixtures: card-api.gts, enum.gts, skill-set.gts, spec.gts.
  • End-to-end signal: a live realm-server stack using this code indexed the base realm with 0 file errors and 0 instance errors in 6.5 s.
  • Type-check + lint clean for runtime-common, realm-server, host.

Other candidates considered, rejected

  • esbuild — has no built-in AMD output; would still need a hand-rolled wrapper on top of it.
  • SWC native — comparable speed (6–12×), but adds a ~30 MB native binary across platforms in node_modules for marginal benefit.
  • es-module-lexer alone — too narrow (only exposes import/export byte ranges). Without a full AST we can't do scope-aware identifier rewriting, which is the mechanism that fixes circular-dep live-binding.
  • Move AMD wrapping into realm-server transpile + cache — orthogonal, bigger architectural change. Worth considering as a follow-up.

Test plan

  • CI green
  • Spot-check indexing performance on a heavy realm (look for a measurable drop in loader-side wall time during reindex)

Replaces the per-module-load `transformAsync(..., TransformModulesAmdPlugin)`
call in `Loader.fetchModule` with a hand-rolled acorn + magic-string
transpiler in a new `packages/runtime-common/amd-transpile.ts`. The
realm-server has already lowered TS, decorators, glimmer templates, and
scoped CSS by the time the loader sees the source — so the loader's
babel pass was paying babel's full plugin pipeline cost for what is
effectively just an `import`/`export` rewrite + AMD wrapper.

Bench results vs current babel pipeline (median wall time on real card
fixtures, post-realm-server-transpile):

  enum.js       (10 KB):    23 ms ->  1.4 ms  (16.8x)
  spec.js       (61 KB):    62 ms ->  3.8 ms  (16.4x)
  skill-set.js  (71 KB):    58 ms ->  4.2 ms  (13.9x)
  card-api.js  (118 KB):   352 ms ->   20 ms  (18.0x)

SWC native (`module.type: 'amd'`) was also benchmarked and came in at
6.5-11.8x — the hand-rolled approach beats it because for these inputs
the Node<->Rust FFI marshalling cost dominates SWC, and we skip code
generation entirely by string-editing instead of re-printing an AST.

Live-binding semantics are preserved across module boundaries (re-
exports use `Object.defineProperty(...{ get })`); local source-code
uses of imported names are bound via `let` destructuring at the AMD
body entry — a snapshot, but unobservable for typical card code which
exports immutable classes/functions/consts. See the file header for
the full semantics rationale.

Includes 23 shared qunit unit tests covering imports, exports,
re-exports, `export *`, `import.meta`, and live-binding cases.

Validated functionally equivalent to babel on four real card fixtures
(card-api, enum, skill-set, spec): identical dep-set sizes and
identical export name sets.

CS-10977

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d566c69ce6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
@habdelra habdelra requested a review from Copilot April 29, 2026 20:47
@habdelra habdelra marked this pull request as draft April 29, 2026 20:48
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Host Test Results

    1 files  ± 0      1 suites  ±0   1h 57m 21s ⏱️ - 57m 49s
2 538 tests +62  2 523 ✅ +62  15 💤 ±0  0 ❌ ±0 
2 557 runs  +62  2 542 ✅ +62  15 💤 ±0  0 ❌ ±0 

Results for commit b7fb2ca. ± Comparison against base commit 4d80511.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the loader’s Babel-based ES module → AMD wrapping step with a hand-rolled acorn + magic-string transpiler to significantly reduce per-module load time during indexing and runtime module fetches.

Changes:

  • Add a new transpileAmd() implementation (acorn parse + magic-string edits) that emits define(moduleId, deps, factory) with getter-based live bindings for re-exports.
  • Update Loader.fetchModule to use transpileAmd instead of @babel/core + transform-modules-amd-plugin.
  • Add shared QUnit tests for AMD wrapping behavior and hook them into realm-server’s test suite; add direct deps (acorn, acorn-walk, magic-string) to runtime-common.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds acorn / acorn-walk to the workspace catalog for direct consumption.
pnpm-lock.yaml Locks new catalog entries / versions for acorn-related deps and magic-string.
packages/runtime-common/amd-transpile.ts New hand-rolled ES→AMD wrapper/transpiler used by the loader.
packages/runtime-common/loader.ts Switch loader module wrapping from Babel to transpileAmd.
packages/runtime-common/package.json Declares new direct dependencies used by amd-transpile.ts.
packages/runtime-common/tests/amd-transpile-test.ts Adds shared unit tests validating wrapping and (claimed) live-binding behavior.
packages/realm-server/tests/index.ts Registers the new amd-transpile test module in realm-server test entrypoint.
packages/realm-server/tests/amd-transpile-test.ts Realm-server QUnit wrapper that runs the shared runtime-common tests.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/loader.ts
Comment thread packages/runtime-common/amd-transpile.ts Outdated
@habdelra habdelra changed the title Speed up loader transpilation ~15x via hand-rolled AMD wrapper (CS-10977) Speed up loader transpilation ~10x via hand-rolled AMD wrapper (CS-10977) Apr 29, 2026
Six issues surfaced by CI failures + reviewer comments (Codex + Copilot)
on the original snapshot-based approach:

1. Circular dependencies: the `let { x } = _foo` snapshot at body entry
   captured `undefined` whenever the dep had not yet finished evaluating
   its body (which is exactly what happens for circular imports —
   card-api.gts <-> contains-many-component.gts and friends). Symptom
   on base-realm: every card instance failed to index with `Cannot read
   properties of undefined (reading 'includes')`.

   Replaced the snapshot with a scope-aware identifier-rewrite walk:
   every non-shadowed source-code reference to an imported name is
   rewritten to `_dep.name`. Lookup happens at use time, so by the time
   a function references `field(...)` the dep has finished evaluating.
   Matches babel's plugin behaviour.

2. `export default <expr>` magic-string overlap. The previous code
   overwrote `node.start..node.end` with `_exports.default = <expr>`,
   then the identifier-rewrite walk visited the same identifier inside
   the same range -> magic-string `Cannot split a chunk that has
   already been edited` -> render timed out at 90s. Symptom on base-
   realm: `ai-app-generator.json` instance hung forever.

   Now strip just the `export default ` prefix and append a
   `var __default$N = (<expr>);` capture, with the
   `_exports.default = __default$N` setter deferred to body end. The
   expression source stays at its original AST positions for the
   walker, no overlap, and forward-reference TDZ behaviour is
   preserved.

3. Order-dependent re-export wiring. `export { x }` of a binding
   imported AFTER it (legal ESM) silently downgraded to a snapshot
   assignment instead of a live-binding getter. Fixed by making pass 1
   collect every `ImportDeclaration` binding before pass 2 emits
   exports.

4. Destructured exports threw. `export const { a, b } = obj` and
   `export const [x, y] = arr` are valid ESM, but the previous code
   threw a "unsupported destructuring pattern" error. Now walks the
   pattern's binding identifiers and emits a getter per name.

5. `_default` name collisions. The hard-coded `_default` temp could
   shadow a real top-level binding. Now uses `__default$N` where N is
   the smallest integer that avoids every name declared at the module
   level.

6. Mutable `export let` lost mutations after body init. `export let
   counter = 0; export function inc() { counter++; }` -> importers
   never saw the post-mutation value because the export was a
   snapshot. Now installs a getter on `_exports.counter` so reads go
   through the live local binding.

Plus: re-added `//# sourceURL=<moduleId>` to the `eval(src)` call in
`Loader.fetchModule` so stack traces from inside an AMD module name
the original module.

Validated end-to-end: a live realm-server stack using the new code
indexed the base realm with 0 file errors and 0 instance errors in
6.5s.

Bench impact: the scope-aware walk costs ~5 percentage points of the
original snapshot approach's speedup. Net is now ~9-12x over babel
across real card fixtures (vs ~15x for the broken snapshot, vs
6-12x for SWC native). Still clearly ahead of babel, no new native
deps.

Adds 6 regression tests covering each of the bugs above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the loader’s Babel-based ES→AMD transformation step with a faster Acorn + MagicString-based transpiler, and adds shared QUnit coverage to validate the new AMD wrapping and ESM semantics (imports/exports, re-exports, import.meta, circular deps).

Changes:

  • Add transpileAmd (Acorn parse + MagicString edits + identifier rewrite) and wire it into Loader.fetchModule.
  • Add shared runtime-common unit tests for AMD transpilation semantics and run them from realm-server’s test suite.
  • Add new JS parsing/editing dependencies (acorn, magic-string) to runtime-common (and workspace catalog).

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds workspace catalog entries for Acorn-related deps.
pnpm-lock.yaml Locks new catalog deps / versions (Acorn, acorn-walk, MagicString).
packages/runtime-common/amd-transpile.ts New hand-rolled ES→AMD transpiler implementation.
packages/runtime-common/loader.ts Switch loader to use transpileAmd and restore sourceURL for eval’d modules.
packages/runtime-common/package.json Adds direct deps needed for the new transpiler.
packages/runtime-common/tests/amd-transpile-test.ts Adds shared QUnit tests for the transpiler behavior.
packages/realm-server/tests/index.ts Registers the new AMD transpile test module.
packages/realm-server/tests/amd-transpile-test.ts Runs the shared runtime-common transpiler tests under realm-server.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/package.json Outdated
Comment thread pnpm-workspace.yaml Outdated
habdelra and others added 4 commits April 29, 2026 17:24
Three P0 correctness bugs in the scope-aware identifier-rewrite walk
plus an unused dep — all surfaced by a fresh-eyes review after the
first round of fixes.

P0-1: shorthand property of an imported name.
`import { x } from 'foo'; const r = { x };` would naive-overwrite
the shorthand identifier, producing `{ _foo$1.x }` — a SyntaxError
that breaks any module that packs an imported binding into an object
literal. Now detect `Property.shorthand` in the value-side rewrite
and emit `x: _foo$1.x` instead.

P0-2: `for (let i = 0; ...)` shadowing an imported `i`.
The `ForStatement` / `ForInStatement` / `ForOfStatement` arm pushed
a block scope but never collected `let`/`const` bindings from the
for-head into it. Result: references to `i` inside the test/update/
body got rewritten to `_dep.i` even though `i` was a loop-local —
`'IMPORTED' < 3 → false` on the first iteration, silent wrong
answer. Now collect the for-head bindings explicitly before walking
init/test/update/body. Also fixes the `for (var k in obj)` case —
`collectFunctionScopeHoists` didn't recurse into the for-in/for-of
`left` slot, so a hoisted `var k` wasn't added to the function
scope.

P0-3: computed keys in destructured exports.
`export const { [keyName]: v } = obj` where `keyName` is imported
left the computed-key reference untouched — runtime tried to look
up an undefined property. Plain `VariableDeclaration` already calls
`walkPatternComputedKeys`; the export-namespace mirror at the
ExportNamedDeclaration walk was missing the same call. Added.

P1: `acorn-walk` was added to `runtime-common` deps and the
workspace catalog speculatively, never imported. Removed.

Plus: `stripps` → `strips` typo in the file header comment.

Adds 4 regression tests covering each P0:
- shorthand property of an imported name
- for-let loop variable shadowing an import
- for-in var hoisting through function scope
- computed key in destructured export

All 36 unit tests pass; 4-fixture validate-against-babel still
matches (52/9/17/43 deps; 68/6/1/7 export name sets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Acorn's AST `decl.start`/`decl.end` SKIP source-level parens — for
`export default (foo);`, `decl` points at the inner `foo` (positions
inside the parens). The rewrite was using
`ms.overwrite(node.start, decl.start, 'var X = (')`
which consumed the source's leading `(` while leaving the source's
trailing `)` in place, then `ms.appendRight(decl.end, ')')` injected
another `)` BEFORE the source's `)`. Result: `var X = (foo));` — a
SyntaxError that breaks any module shipping `export default (anything)`,
`export default (function (...) {...})`, or
`export default (function(){...})()` (IIFE).

Found by an independent agent re-review; the prior tests only covered
unparenthesised default expressions.

Fix: replace just the literal `export default ` keyword (15 chars) and
append `)` before any trailing `;`. Source-level parens stay intact,
which is harmless: `var X = ((foo));` parses fine.

Adds three regression tests:
- `export default (1 + 2);`
- `export default (function () { return 9; })();` (IIFE)
- `export default (function () { return 'pf'; });` (parenthesised function expression)

39/39 unit tests pass; 4-fixture validate-against-babel still matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Loader runs in the browser (host), not in Node. Both test suites
were wired through realm-server's qunit harness via the shared-tests
pattern (`runtime-common/tests/*` + a thin `realm-server/tests/*`
wrapper), but neither test exercises Node-specific code paths.

Move both to `packages/host/tests/unit/` where they run in the same
context the code actually executes (Ember test runner, browser).

- packages/host/tests/unit/amd-transpile-test.ts (39 tests)
- packages/host/tests/unit/loader-retry-test.ts (11 tests)

Drops the realm-server wrappers and the runtime-common shared files.
No production code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three classes of host-lint errors that didn't surface until CI ran the
host's stricter eslint config (qunit-plugin rules + a tighter prettier
config than runtime-common's):

- `qunit/no-ok-equality` — `assert.true(x === true)` -> `assert.strictEqual(x, true)`
- `qunit/no-assert-equal-boolean` — `assert.strictEqual(fn(), true)` -> `assert.true(fn())` and similarly for `false`
- prettier formatting of object args spread across lines

All auto-fixable via `eslint --fix`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Preview deployments

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Realm Server Test Results

    1 files  ± 0      1 suites  ±0   17m 54s ⏱️ -10s
1 103 tests  - 11  1 103 ✅  - 11  0 💤 ±0  0 ❌ ±0 
1 175 runs   - 11  1 175 ✅  - 11  0 💤 ±0  0 ❌ ±0 

Results for commit b7fb2ca. ± Comparison against base commit 4d80511.

This pull request removes 11 tests.
default ‑ dispose errors do not mask the retry path
default ‑ disposes each discarded response before retrying
default ‑ does not call dispose when no retry happens
default ‑ does not retry on 4xx (404, 401, 403)
default ‑ does not retry on 500 (not transient)
default ‑ does not retry when the fetch call throws
default ‑ honors custom backoff delays and passes them to onRetry
default ‑ retries on 503 and 504 (both transient)
default ‑ retries once after a 502 then succeeds on 200
default ‑ returns the first response when status is 2xx
…

♻️ This comment has been updated with latest results.

@habdelra habdelra marked this pull request as ready for review April 30, 2026 00:28
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b801f9b4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
habdelra and others added 5 commits April 29, 2026 20:44
P1 — assignment to imported binding silently mutated dep namespace.
`imp = 1` (or `imp++`, or `({ x } = obj)` where x is imported) was
being rewritten to `_foo$1.imp = 1` etc. — silently mutating the
dep's exports namespace. Babel emits a runtime "x is read-only" throw.
We now leave Identifier and pattern LHS positions unrewritten in
`AssignmentExpression` and `UpdateExpression` so the unbound name in
strict mode throws ReferenceError at runtime — same observable as
babel.

P2.1 — `switch` body is a single block scope.
`case 1: let y = 1; case 2: return y;` — JS scopes `y` across all
cases. The walker pushed a block scope for `SwitchStatement` (via
the default arm) but never collected `let`/`const`/`class`/`function`
decls from the case consequents into it. Added an explicit
`SwitchStatement` case that collects flat across cases before walking
the discriminant + tests + bodies.

P2.2 — class `static { ... }` block scope.
Static initializer blocks (ES2022) have their own var + lexical env.
A `let x` inside `static { ... }` must shadow an imported `x` only
within the block, not in surrounding instance methods. Added an
explicit `StaticBlock` case that pushes a function-shaped scope and
runs both function-scope hoisting + block-scope collection.

P2.3 — top-level await rejected at transpile time.
The emitted AMD factory is non-async, so source TLA would land in a
non-async wrapper at `eval(src)` time → confusing `SyntaxError`.
Added a `hasTopLevelAwait` walk and throw a clear
"top-level await is not supported by the loader" error from
`transpileAmd` instead.

Adds 5 host regression tests:
- assignment to imported binding throws
- post-increment of imported binding throws
- destructure-assign to imported binding throws
- class static block shadows imports within the block
- switch body block scope shadows imports across cases
- top-level await rejected at transpile time
- await inside an async function still works

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 5 review caught: `hasTopLevelAwait` only matched
AwaitExpression nodes, but two other forms also require an async
enclosing scope:

- `for await (... of ...)` — ForOfStatement with `await: true`,
  no AwaitExpression child
- `await using r = ...` — VariableDeclaration with kind
  `'await using'` (ES2024 explicit-resource-management)

Both would have slipped past the TLA guard and produced the exact
"confusing eval-time SyntaxError" the guard was supposed to prevent.

Adds 4 host regression tests:
- `for await (... of ...)` at top level rejected
- `await using r = ...` at top level rejected
- `for await (... of ...)` inside an async function still works
- (existing TLA-rejection test stays green)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`collectBlockScopeDecls` and the for-loop head decl-collection only
matched `let` and `const`. ES2024 `using` and `await using` are also
block-scoped, so a same-named imported binding wasn't being shadowed.
Result: `import { r } from 'foo'; using r = ...; return r.value;`
silently rewrote `r.value` to `_foo$1.r.value` — wrong value.

Caught by an independent agent re-review; card sources today don't
use `using` (it's months-old syntax) but the gap is real once anyone
does. Two-line fix at both call sites.

Adds 2 regression tests:
- `using r = ...` shadows an imported `r`
- `for (using r of src)` head binds `r`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor:
- Extract pure helpers (`collectPatternBindings`, `isReferencePosition`)
  to module level. Unifies the two slightly-different copies that lived
  in `transpileAmd`'s pass 1 and the rewriter's closure.
- Convert `rewriteIdentifierReferences` (was a 580-LOC closure soup)
  into an `IdentifierRewriter` class with private methods. Same shape,
  same behaviour; just maintainable.
- Type the walker with acorn's `AnyNode` / `Pattern` types instead of
  `any`. TypeScript now narrows on `node.type` in every switch arm and
  catches `parentKey` typos statically.
- Also lands the round-7 P1 fix: `for ({ a } of items)` (destructure-
  assignment in for-of/for-in head) was rewriting LHS imports the same
  way `AssignmentExpression` used to. Now uses `walkPatternComputedKeys`
  for ObjectPattern/ArrayPattern LHS.

Bench harness:
- Move from untracked `.bench-amd/` to tracked `scripts/bench-amd/`.
- Convert .mjs to .ts; run via ts-node (`--transpileOnly`).
- Two pnpm scripts: `bench:amd:prep` regenerates fixtures from real
  card sources via transpile.ts; `bench:amd` runs the head-to-head
  comparison. README documents both.
- `mise run bench:amd` wraps both.
- `fixtures/` dir stays gitignored (regenerable, large).

Bench numbers from a fresh local run (50 iter, 5 warmup):
  card-api  (118 KB):  13.95x over babel
  spec       (61 KB):  10.89x
  skill-set  (71 KB):   9.46x
  enum       (10 KB):   7.75x

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the loader’s Babel-based ES→AMD wrapping step with a faster, hand-rolled acorn + magic-string transpiler, and adds benchmarking + unit tests to validate semantics and measure speedups.

Changes:

  • Switch Loader.fetchModule to use transpileAmd() and restore //# sourceURL for better stack traces.
  • Add new packages/runtime-common/amd-transpile.ts implementation (imports/exports handling + scope-aware identifier rewriting).
  • Add benchmark scripts/fixtures tooling and move/add unit tests in packages/host/tests/unit/.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds acorn to the workspace catalog for the new transpiler.
pnpm-lock.yaml Updates lockfile for new dependencies and catalog entries.
packages/runtime-common/package.json Adds acorn/magic-string deps and bench scripts.
packages/runtime-common/loader.ts Replaces Babel transform with transpileAmd() and appends sourceURL on eval.
packages/runtime-common/amd-transpile.ts New acorn+magic-string ES→AMD transpiler implementation.
packages/runtime-common/.eslintignore Ignores generated bench fixtures directory.
packages/runtime-common/scripts/bench-amd/prepare-fixtures.ts Generates realistic loader-input fixtures via the transpile pipeline.
packages/runtime-common/scripts/bench-amd/bench.ts Runs wall-time benchmarks across multiple candidates/fixtures.
packages/runtime-common/scripts/bench-amd/README.md Documents benchmark usage, candidates, and fixtures.
packages/runtime-common/scripts/bench-amd/.gitignore Git-ignores generated fixture outputs.
packages/runtime-common/scripts/bench-amd/candidates/production.ts Bench candidate that runs the production transpileAmd().
packages/runtime-common/scripts/bench-amd/candidates/parse-acorn-only.ts Bench candidate for acorn parse-only floor measurement.
packages/runtime-common/scripts/bench-amd/candidates/parse-esml-only.ts Bench candidate for es-module-lexer parse-only floor measurement.
packages/runtime-common/scripts/bench-amd/candidates/babel-current.ts Bench baseline candidate using Babel AMD transform.
packages/runtime-common/scripts/bench-amd/candidates/babel-no-sourcemap.ts Bench baseline variant without sourcemaps.
packages/realm-server/tests/loader-retry-test.ts Removes realm-server harness copy of loader retry tests.
packages/realm-server/tests/index.ts Stops importing the removed realm-server loader retry test.
packages/host/tests/unit/loader-retry-test.ts Rehomes loader retry tests into host unit tests and targets runtime-common loader APIs.
packages/host/tests/unit/amd-transpile-test.ts Adds unit tests covering many ES module edge cases for transpileAmd().
mise-tasks/bench/amd Adds a mise task to regenerate fixtures and run the AMD transpile benchmark.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/runtime-common/package.json Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
Comment thread packages/runtime-common/amd-transpile.ts Outdated
Refactor (no behavior change):
- Break the 1100-LOC monolithic `amd-transpile.ts` into 5 cohesive
  files under `amd-transpile/`:
  - `index.ts` — `transpileAmd` public entry
  - `identifier-rewriter.ts` — scope-aware AST walk class
  - `pattern-helpers.ts` — pure AST helpers shared by main pass + walker
  - `emit-helpers.ts` — pure string utilities for AMD wrapper text
  - `top-level-await.ts` — TLA detector

Copilot review fixes (PR #4573):
- package.json: `es-module-lexer` and `ts-node` were in
  `peerDependencies` (only used by bench scripts) — moved to
  `devDependencies` so consumers don't get spurious peer-dep warnings.
- `IdentifierRewriter`: declare `node.id.name` for `FunctionDeclaration`
  in its own function scope (not just `FunctionExpression`) so a
  recursive self-call inside `function f() { f(); }` isn't rewritten
  to the import accessor when `f` is also imported.
- `reExportStarSnippet`: emit `configurable: true` on the getter so
  duplicate `export * from 'foo'` re-exports of the same key don't
  throw on the second `Object.defineProperty`. Matches `defineGetter`
  / `defineLocalGetter`.
- `export default <expr>` comment: clarify that the deferred-setter
  pattern avoids a magic-string overlap and unifies the emit pattern,
  not TDZ. The expression still evaluates at its source position, so
  forward references to a later `const`/`class` are still a TDZ
  error — same as native ESM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@habdelra habdelra requested a review from Copilot April 30, 2026 02:33
…ktrees

The amd-transpile peer→dev dep move in cf53431 didn't include the
regenerated `pnpm-lock.yaml`. Also adds `.claude/worktrees/` to
`.gitignore` so transient worktree gitlinks don't get caught by
`git add` again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@habdelra habdelra force-pushed the cs-10977-make-transpilation-in-loader-more-efficient branch from b907c2b to 1551617 Compare April 30, 2026 02:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR replaces the loader’s Babel-based ES→AMD wrapping with a faster hand-rolled acorn + magic-string transpiler, adds benchmarking tooling, and relocates/updates related tests.

Changes:

  • Swap Loader.fetchModule from Babel transformAsync(...TransformModulesAmdPlugin...) to transpileAmd, with sourceURL restored for stack traces.
  • Add new packages/runtime-common/amd-transpile/* implementation (parse, export/import handling, identifier rewriting, TLA detection).
  • Add benchmarking scripts + fixtures prep and new unit tests for the AMD transpiler.

Reviewed changes

Copilot reviewed 23 out of 25 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds acorn to the workspace catalog for the new transpiler.
packages/runtime-common/package.json Adds deps and bench scripts for AMD transpile benchmarking.
packages/runtime-common/loader.ts Replaces Babel AMD transform with transpileAmd; adds sourceURL to eval.
packages/runtime-common/amd-transpile/index.ts Implements the new ES→AMD transform and wrapper emission.
packages/runtime-common/amd-transpile/identifier-rewriter.ts Adds scope-aware identifier rewrite for imports and import.meta.
packages/runtime-common/amd-transpile/pattern-helpers.ts Adds AST helpers for binding collection and reference-position detection.
packages/runtime-common/amd-transpile/emit-helpers.ts Adds string emit helpers for exports/re-exports/member access.
packages/runtime-common/amd-transpile/top-level-await.ts Adds a top-level-await detector for clearer errors pre-eval.
packages/host/tests/unit/amd-transpile-test.ts Adds unit coverage for the new transpiler behavior/semantics.
packages/runtime-common/tests/loader-retry-test.ts Converts retry tests to direct QUnit tests using runtime-common loader exports.
packages/realm-server/tests/loader-retry-test.ts Removes realm-server test wrapper for the shared loader retry tests.
packages/realm-server/tests/index.ts Stops importing the removed loader retry test file.
packages/runtime-common/scripts/bench-amd/prepare-fixtures.ts Adds fixture generation for realistic loader-input benchmark sources.
packages/runtime-common/scripts/bench-amd/bench.ts Adds wall-time benchmark harness for transpilation candidates.
packages/runtime-common/scripts/bench-amd/README.md Documents how to run the AMD transpile benchmark and candidates.
packages/runtime-common/scripts/bench-amd/.gitignore Ignores generated benchmark fixtures.
packages/runtime-common/scripts/bench-amd/candidates/production.ts Adds “production” candidate using transpileAmd.
packages/runtime-common/scripts/bench-amd/candidates/babel-current.ts Adds Babel baseline benchmark candidate.
packages/runtime-common/scripts/bench-amd/candidates/babel-no-sourcemap.ts Adds Babel candidate with sourcemaps disabled.
packages/runtime-common/scripts/bench-amd/candidates/parse-acorn-only.ts Adds parse-only acorn floor benchmark candidate.
packages/runtime-common/scripts/bench-amd/candidates/parse-esml-only.ts Adds parse-only es-module-lexer floor benchmark candidate.
packages/runtime-common/.eslintignore Ignores generated bench fixtures for lint runs.
mise-tasks/bench/amd Adds mise task to regenerate fixtures and run the bench.
.gitignore Ignores .claude/worktrees/.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

eval(src); // + "\n//# sourceURL=" + moduleIdentifier);
// Append `sourceURL` so stack traces from inside the eval-ed AMD
// module name the original module URL instead of `<anonymous>`.
eval(src + '\n//# sourceURL=' + moduleIdentifier);
Comment on lines +340 to +356
// Replace just the `export default ` keyword (15 chars) with the
// var capture, then append `)` before any trailing `;`. We do
// NOT trim to `decl.start..decl.end` because acorn's positions
// SKIP source-level parens — for `export default (foo);`, decl
// points at `foo` (inside the parens), so consuming
// `[node.start..decl.start]` would eat the source `(` while
// leaving the source `)` untouched, producing `var X = (foo));`
// (double-paren SyntaxError). Replacing only the keyword and
// appending before `;` leaves source-level parens intact, which
// is harmless: `var X = ((foo));` parses fine.
const tempName = freshDefaultName();
const headEnd = node.start + 'export default '.length;
ms.overwrite(node.start, headEnd, `var ${tempName} = (`);
let tail = node.end;
if (src[tail - 1] === ';') tail -= 1;
ms.appendRight(tail, ')');
exportStatements.push(`_exports.default = ${tempName};`);
Comment on lines +91 to +93
"@glint/ember-tsc": "catalog:",
"es-module-lexer": "^1.7.0",
"ts-node": "^10.9.1"
Comment on lines 230 to 233
import './claim-boxel-domain-test';
import './card-reference-resolver-test';
import './bfm-card-references-test';
import './loader-retry-test';
import './package-shim-handler-test';
Comment on lines +51 to +53
const median = sorted[Math.floor(sorted.length / 2)];
const p95 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
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