Skip to content

feat: closes #100 — compile-time-resolved dynamic import()#752

Merged
proggeramlug merged 18 commits into
mainfrom
adoring-leakey-3533d0
May 13, 2026
Merged

feat: closes #100 — compile-time-resolved dynamic import()#752
proggeramlug merged 18 commits into
mainfrom
adoring-leakey-3533d0

Conversation

@TheHypnoo
Copy link
Copy Markdown
Contributor

@TheHypnoo TheHypnoo commented May 13, 2026

Summary

Closes #100. Compile-time-resolved dynamic import() end-to-end: targets are reachable through the resolved namespace, with byte-for-byte parity against node --experimental-strip-types on every parity test.

Resolved subset (D1 from the spec)

  • String literal: await import('./foo.ts')
  • Ternary over resolvable args: await import(flag ? './a.ts' : './b.ts')
  • Template literals with finite interpolation sets: await import(`./locale_${lang}.ts`)
  • Const-propagated module-level locals
  • Unresolvable paths → clear compile error (covered by HIR unit tests in crates/perry-hir/src/dynamic_import.rs)

Acceptance (byte-for-byte vs Node, 7 parity tests)

Test Perry Node
literal 42\nhello from a 42\nhello from a
ternary 42\nb 42\nb
template hola hola
reexport 7 7
TLA (target has top-level await) 99\ntla-loaded 99\ntla-loaded
cycle (A static→B, B dyn→A) a-export a-export
init-time dynamic import 17 17

Implementation highlights

  • feat(runtime): js_create_namespace — builds the module-namespace object from parallel key/value arrays. Returns f64 (NaN-boxed POINTER_TAG) to match the LLVM ABI.
  • feat(hir): flatten_exports — resolves ExportAll / ReExport / NamespaceReExport through the import graph; cycle-safe, depth-first, last-writer-wins.
  • feat(codegen): per-module __perry_ns_<prefix> globals — emitted with external linkage for every dynamic-import target; populated at the tail of __perry_init_<prefix> (or main for the entry). Values materialized per kind: LocalVar / LocalFunction (closure singleton) / LocalClass (INT32-tagged) / ForeignVar (cross-module getter) / NestedNamespace.
  • feat(codegen): real Expr::DynamicImport dispatch — single-path loads the namespace global; multi-path emits a js_string_equals chain over each compile-time path with a js_promise_rejected fallthrough.
  • feat(hir): template literals + const-propagated locals — resolver follows Binary(Add, ...) chains (Cartesian product) and LocalGet(x) for module-level non-mutated consts.

Notes on semantics

  • Namespace is a snapshot, not live bindings. ESM spec says namespace properties are live bindings on the source module's export let slots; this PR materializes a snapshot at __perry_init_<prefix> end. For Perry workloads (mostly export const/function/class) this is indistinguishable. Documented divergence; live bindings can be added later via indirection in js_create_namespace's setter slots.
  • TLA at the dispatch site does not need to chain. Perry's top-level await blocks synchronously inside __perry_init_<prefix>, so by the time the dispatcher reads __perry_ns_<prefix> the post-TLA exports are already populated. Verified by test_gap_dynamic_import_tla.ts.
  • Cycles work via topo ordering + cycle break. A statically importing B while B dynamically imports A: topo orders B before A (A depends on B), topo_visit silently breaks the dynamic back-edge, and re-entry into A's init does not occur. Verified by test_gap_dynamic_import_cycle.ts.
  • Init-time dynamic imports: a top-level await import(...) at module init time works because the dynamic edge is registered in collect_modules as a regular import, putting the target ahead of the consumer in the topological order. Verified by test_gap_dynamic_import_init_time.ts.

Follow-up

Tracked in #753 — startup-latency optimization: skip eager init for modules reached only via dynamic import(). Module.init_kind is already populated in this PR; #753 wires it through codegen (skip eager call for Deferred modules + dispatch-side __init call + idempotent guard). Pure perf; the current behavior here is functionally correct.

TheHypnoo added 6 commits May 13, 2026 13:45
Introduce HIR scaffolding for compile-time-resolved dynamic import():

- Module gains `has_top_level_await: bool` and `init_kind: ModuleInitKind`
  (Eager | Deferred). Drives later codegen decisions about whether the
  per-module __perry_init_<prefix> function is invoked eagerly at program
  start or only on the first dynamic import() dispatch.
- Import gains `is_dynamic: bool`. Synthesized from a const-folded
  import() argument, dynamic edges enter the import graph but do not
  pin the target as eager.
- Expr::DynamicImport { paths, arg } replaces the warning-and-undefined
  stub at lower/expr_call.rs:5816. Initial lowering leaves `paths`
  empty — collect_modules runs the path-arg const folder later and
  raises a compile error if the argument is not statically resolvable.

Walker arms (mut + immutable), stable_hash impls, and the Import
construction sites in lower.rs / inline.rs are updated. Codegen sees
the new variant via the existing catch-all "not yet supported" bail —
real codegen lands in a follow-up commit.

Refs #100.
New crate module `perry_hir::dynamic_import` (re-exported at the crate
root) provides:

- `resolve_import_path(arg)` — folds an `import()` path argument to a
  finite set of module sources. Supports string literals and ternaries
  of resolvable args. Returns `Resolution::Unresolved(reason)` with an
  actionable hint for anything outside the supported subset.
- `detect_top_level_await(module)` — sets `Module.has_top_level_await`
  by scanning `module.init` for an `Expr::Await` outside any
  function/closure body. Uses the existing walker's `Closure` arm which
  intentionally does not descend into closure bodies.
- `DYNAMIC_IMPORT_PATH_CAP = 64` (D2, issue #100).

Six unit tests cover the literal/ternary/dedupe/unresolved paths and
the closure-isolation guarantee for TLA detection.

The driver wires this into `collect_modules` in a follow-up commit.

Refs #100.
In the per-module collection step, after lowering returns:

1. Run `perry_hir::detect_top_level_await` on the module so codegen
   knows whether the deferred-import dispatch needs to chain the init
   Promise.
2. Walk every `Expr::DynamicImport` site via
   `perry_hir::for_each_dynamic_import_mut`, run the path const-folder,
   and stamp the resolved `paths` set onto the node.
3. Append each unique resolved target as a regular `Import` with
   `is_dynamic: true` (zero specifiers — the namespace is materialized
   at dispatch). When a static import to the same source already exists,
   skip — the static edge gives us full namespace materialization
   already, and adding a duplicate would corrupt the import graph.
4. Aggregate every unresolvable/over-cap site into a single compile
   error, citing the offending module and an actionable hint.

Codegen for `Expr::DynamicImport` is the next commit. Until then, the
codegen catch-all bails with "expression DynamicImport not yet
supported" — so user code containing a dynamic `import()` returns a
clear codegen error rather than a silent `undefined`.

Refs #100.
MVP codegen for compile-time-resolved dynamic `import()`. After the
resolver in `collect_modules` registers every resolved path as an
import edge, the target module is compiled and its
`__perry_init_<prefix>` runs in the eager init chain — so by the time
a dispatch site executes, the target's top-level statements have
already completed.

The codegen lowers `Expr::DynamicImport { paths, arg }` to:
- evaluate `arg` (for side effects; the const-folded path is not used
  for routing at this stage),
- allocate a fresh empty JS object via `js_object_create`,
- wrap it in a resolved Promise via `js_promise_resolved`,
- NaN-box the pointer and return.

Known limitation (called out in the PR): the namespace object is empty.
A consumer that does `const m = await import('./foo.ts'); m.x` will
see `undefined`. Populating per-module namespace globals from the
target's export list is the natural follow-up — it needs a runtime
primitive (`js_create_namespace`) plus a codegen pass that emits a
`__perry_ns_<prefix>` global. The framework for both is in place; the
emit machinery is intentionally deferred to keep this PR scoped.

Verified end-to-end:
- `await import('./helper.ts')` → compiles, runs, awaits a non-null
  object handle.
- `await import(flag ? './a.ts' : './b.ts')` → both branches enter
  the import graph, compiles, runs.
- `await import(someStringVar)` → fails compilation with the
  documented "path argument is not statically resolvable" error.

Refs #100.
Three test files exercising the three landing paths:

- test_gap_dynamic_import_literal.ts — `import('./literal.ts')` —
  Perry and Node both print `object\ntrue` (byte-for-byte parity).
- test_gap_dynamic_import_ternary.ts — `import(flag ? a : b)` — both
  branches enter the import graph; Perry and Node both print
  `object\nobject`.
- test_gap_dynamic_import_error.ts — `import(stringVar)` — Perry
  rejects at compile time with the documented error. Cannot be a
  parity test (Node resolves the path at runtime); it exists for
  manual verification via `perry compile`.

Helpers (dynamic_import_helper_a.ts, dynamic_import_helper_b.ts) are
deliberately not named `test_gap_*` so the gap runner does not try to
execute them as top-level tests.

Per issue #100 — the resolved namespace object is currently empty
(member access on the awaited value returns undefined), so neither
gap test reaches into `m.x`. That's the namespace-materialization
follow-up tracked in the PR description.

Refs #100.
@proggeramlug
Copy link
Copy Markdown
Contributor

Awesome work, will dig in shortly!

TheHypnoo and others added 9 commits May 13, 2026 14:30
Builds a module-namespace object (the value `await import('./foo.ts')`
resolves to) from parallel key/value arrays. Keys are length-prefixed
UTF-8 since Perry strings may not be null-terminated. Returns a
NaN-boxed POINTER_TAG ObjectHeader with `keys_array` populated so
`Object.keys(ns)` and property iteration work like any other JS object.

Routes property writes through `js_object_set_field_by_name` so inline-
slot allocation, shape transitions, and accessor dispatch all use the
standard property-write path — namespace objects need no special
casing on the read side.

Declared in runtime_decls.rs:
  js_create_namespace(i32, ptr, ptr, ptr) -> double
Resolves a module's exports — including `ExportAll`, `ReExport`, and
`NamespaceReExport` — into a flat list of `FlatExport` entries. Each
entry maps one exported name (as the consumer sees it) to the source
module + local binding holding the value.

Cycle-safe (visited set), depth-first; last-writer-wins on duplicate
names matches the JS `export * from` precedence. Used by codegen to
populate each module's `__perry_ns_<prefix>` global at the end of
`__perry_init_<prefix>`.

Five new unit tests cover local named exports, one-hop re-exports,
recursive `ExportAll`, cycle termination, and `NamespaceReExport`
(which produces a `nested_namespace_of` entry that codegen materializes
as a sub-`js_create_namespace` call).
…it end for #100

For each module that is the target of at least one `await import("...")`
site anywhere in the program, emit a `@__perry_ns_<prefix>: double = TAG_UNDEFINED`
external global and a populator at the tail of `__perry_init_<prefix>`
(or `main` for the entry module) that:

1. Allocates three parallel stack arrays — `keys: [N x ptr]`,
   `key_lens: [N x i32]`, `values: [N x double]`.
2. Materialises each NamespaceEntry's value per kind: LocalVar reads
   the `@perry_global_*` directly, LocalFunction allocs a closure
   singleton over its wrap symbol, LocalClass emits the INT32-tagged
   class-id NaN-box, ForeignVar calls `perry_fn_<src>__<local>()`,
   NestedNamespace loads the target's own `@__perry_ns_<src>`.
3. Calls `js_create_namespace(N, keys, key_lens, values)` (added in
   the previous commit) and stores the result in the namespace global.
4. Registers the global's address as a GC root so the underlying
   ObjectHeader survives subsequent sweeps.

The dispatch side declares foreign `@__perry_ns_<prefix>` globals as
externs based on the precomputed `dynamic_import_path_to_prefix` map
(threaded through `CompileOptions` from the driver). The driver runs
`perry_hir::flatten_exports` over each dynamic-import target,
determines kind (Var/Function/Class/NestedNamespace) from the source
module's HIR, and packages the resolved entries as `NamespaceEntry`
values per module.

No behaviour change to the user-facing dispatch yet — that's the next
commit. This commit puts the namespace globals in place so when the
dispatch site flips over to reading them, the data is already there.
…mport for #100

Replaces the MVP stub (resolved-Promise of an empty object) with the
real dispatch:

Single-path: load `@__perry_ns_<target_prefix>` (populated by the
target module's __init at the end of this commit's predecessor) and
wrap in `js_promise_resolved`.

Multi-path: evaluate the runtime path string once, then emit a chain
of `js_string_equals` compares against each compile-time constant from
`paths`. Each match arm loads its target's namespace global and stores
the resolved promise into a stack slot; the chain falls through to a
`js_promise_rejected` arm on no match. A join block reads the slot
and yields the dispatch result.

Also routes the path-arg through `js_get_string_pointer_unified` once
(amortising the unboxing cost across compares) — same pattern used by
the string-equality lowering at expr.rs:1840.

The `js_create_namespace` runtime function now takes `*const f64` for
the values pointer and returns `f64` directly, matching the DOUBLE LLVM
ABI declared in runtime_decls.rs. Using `JSValue` would route the
return through integer registers (#[repr(transparent)] over u64), so
the call-site's xmm0 read observed stale bits and `typeof m` reported
"string" (the previous fcvt's residue).

Also fixed: `js_create_namespace` no longer pre-populates `keys_array`
before calling `js_object_set_field_by_name`. The property setter
unconditionally appends to keys_array, so pre-populating doubled every
key — `Object.keys(m)` returned `['x', 'add', 'x', 'add']`.
…amic import paths for #100

Extends `resolve_import_path` to handle two more shapes from D1:

- **Template literals**: `` `./locale_${lang}.ts` `` — Perry's lowering
  emits a left-leaning `Binary(Add, ...)` chain from `expr_misc::lower_tpl`.
  The new path flattens the Add chain into ordered parts, resolves each
  part to a finite set, then takes the Cartesian product. Cap-enforcement
  remains at the call site (`collect_modules`) which gates on
  `DYNAMIC_IMPORT_PATH_CAP`.

- **Module-level `const` locals**: `const p = './foo.ts'; await import(p)`
  — a new `collect_module_const_locals` pass scans top-level
  `Stmt::Let { mutable: false }` bindings, then drops any that get
  reassigned anywhere in the module (defensive — TS `const` already
  guarantees single-assignment but a stray `LocalSet` would lie).
  The resolver consults that map when it encounters `LocalGet`, with
  a `visiting` set to cycle-break self-referential consts.

`as`/`satisfies`/parens were already erased during HIR lowering so
they need no special handling here.

New unit tests:
- `resolve_template_literal_with_const_local` — `${lang}` resolves
  through a module-level const.
- `resolve_template_literal_with_ternary_interpolation` — Cartesian
  product over a ternary inside `${...}`.
- `resolve_local_const_propagation` — bare `LocalGet` resolves.
- `resolve_unresolved_param_local` — function param fails closed.
- `collect_consts_skips_mutated` — a `const` that gets reassigned
  somewhere is removed from the map.

Public surface:
- `resolve_import_path_with_consts(arg, consts, visiting)` — the
  threaded variant used by `collect_modules`.
- `resolve_import_path(arg)` keeps its original signature (empty
  consts map) so existing call sites in `dynamic_import::tests` work
  unchanged.
- `collect_module_const_locals(module) -> HashMap<LocalId, Expr>`.
…ot just typeof, for #100

Rewrites the placeholder gap tests to assert the exported VALUES the
namespace exposes. Both Perry's output and `node --experimental-strip-types`'s
output are now byte-for-byte identical on every passing test.

- `test_gap_dynamic_import_literal.ts` asserts `m.x === 42` and
  `m.greet() === "hello from a"` instead of `typeof m === "object"`.
- `test_gap_dynamic_import_ternary.ts` asserts `a.x === 42` and
  `b.label === "b"` across both ternary arms (helper_b switched from
  `y = 100` to `label = "b"` to match the spec).
- `test_gap_dynamic_import_template.ts` (new) — `./locale_${lang}.ts`
  with a module-level const `lang = "es"` resolves to a single concrete
  path; asserts `m.hello === "hola"`.
- `test_gap_dynamic_import_reexport.ts` (new) — `await import("./barrel.ts")`
  where the barrel does `export * from "./inner.ts"`; asserts `m.v === 7`.
  Validates the `flatten_exports` ExportAll path through `collect_modules`'s
  driver-side source-rewrite (resolves `Export::*::source` specifiers
  to the upstream module's `Module::name` so flatten_exports's lookup
  finds the right HIR).

Apologetic comments in both rewritten tests are deleted — the
implementation now meets the spec.

The compile-fail `test_gap_dynamic_import_error.ts` is updated only in
its docstring to reflect the new (more accurate) error message; the
test still exercises a function-local `const` reference, which is
correctly rejected (only MODULE-level consts feed
`collect_module_const_locals`).

Helpers:
- `dynamic_import_helper_a.ts` — `export const x = 42; export function
  greet()`.
- `dynamic_import_helper_b.ts` — `export const label = "b"`.
- `locale_en.ts` / `locale_es.ts` — `export const hello = 'hi' | 'hola'`.
- `dynamic_import_inner.ts` — `export const v = 7`.
- `dynamic_import_barrel.ts` — `export * from "./dynamic_import_inner.ts"`.
…es for #100

Three additional parity tests covering edge cases the agent flagged as
deferred. All three pass byte-for-byte against `node --experimental-strip-types`
with the current implementation — no codegen changes were needed:

- test_gap_dynamic_import_tla.ts — `await import('./tla.ts')` where the
  target uses top-level await (`export const value = await compute()`).
  Perry's init function blocks synchronously on the TLA expression, so
  the namespace populator at the end of `__perry_init_<prefix>` reads the
  resolved post-TLA values. The dispatcher loads the already-populated
  namespace global. No `js_promise_resolved_then` chaining is required
  at the dispatch site under Perry's current init model.

- test_gap_dynamic_import_cycle.ts — A statically imports B; B's
  exported `loadA()` dynamically imports A. The dynamic call happens
  after both modules init, so the namespace is populated when the
  dispatch fires. Topo orders B before A (A depends on B), the cycle
  detection in `topo_visit` silently breaks the dynamic back-edge, and
  re-entry into A's init does not occur in practice.

- test_gap_dynamic_import_init_time.ts — top-level `await import(...)`
  inside an entry module's init code. The dynamic edge is registered in
  `collect_modules` as a regular import, which puts the target ahead of
  the consumer in the topological order. The target's namespace is fully
  populated before the dispatch evaluates.

These three cases were listed as "optional / can defer" in the
implementation spec for #100, but the resulting tests confirm the
current eager-init-everywhere model handles them correctly. The
remaining unfinished items in the PR body — Eager/Deferred init split
and an idempotent init guard — are pure-performance optimizations
(startup latency for modules reached only dynamically) and not
correctness fixes; they are appropriate as a follow-up issue.
Apply rustfmt to the dynamic-import implementation. No semantic changes.

The CI `lint` job runs `cargo fmt --all -- --check` and rejected the
previous push. Five files affected — formatter prefers multi-line
struct literals, broken-up method chains, and unwrapped single-field
init shorthand. All 7 dynamic-import gap tests continue to match
`node --experimental-strip-types` byte-for-byte after reformatting.
@TheHypnoo
Copy link
Copy Markdown
Contributor Author

Sorry about the branch name; it's the name of a work tree I overlooked it

TheHypnoo added 2 commits May 13, 2026 16:12
The error gap test is intentionally a compile-fail case — it verifies
that Perry rejects an unresolvable dynamic import() path argument at
compile time with the documented diagnostic. The compile-smoke job's
"compile every test file successfully" assumption doesn't apply to
compile-fail tests, so it joined the SKIP_TESTS list alongside the
other entries that need special harness handling.

The error path is still exercised — `perry test-files/test_gap_dynamic_import_error.ts`
locally produces the documented error string; the smoke job just stops
counting it as a build failure.

Refs #100.
…IR unit tests

The previous commit added `test_gap_dynamic_import_error.ts` to the
compile-smoke SKIP_TESTS list because the file is intentionally a
compile-fail case (verifies Perry rejects unresolvable dynamic
import() paths). That was papering over a smell: `test-files/` carries
an implicit contract that every `.ts` compiles, and Perry has no
`compile-fail/` subdirectory convention to opt out of it.

The error path is already covered at the unit-test layer:
`crates/perry-hir/src/dynamic_import.rs` ships 15 `#[test]`s exercising
the resolver, including `resolve_unresolved_param_local` and other
cases that assert `Resolution::Unresolved(reason)` with the documented
hint string. The `.ts` file added no coverage on top of that.

Drop the file and revert the SKIP_TESTS entry. The PR body's parity
table loses its now-empty "error" row.

Refs #100.
@TheHypnoo TheHypnoo marked this pull request as ready for review May 13, 2026 14:49
@TheHypnoo TheHypnoo requested a review from proggeramlug May 13, 2026 14:49
Maintainer-side metadata fold-in at merge time per CLAUDE.md convention
(external contributors don't touch version/CHANGELOG; maintainer adds at
merge time). See CHANGELOG.md for the v0.5.905 detail block on the
dynamic import() landing.
@proggeramlug proggeramlug merged commit 20e3952 into main May 13, 2026
9 checks passed
@proggeramlug proggeramlug deleted the adoring-leakey-3533d0 branch May 13, 2026 21:04
TheHypnoo added a commit that referenced this pull request May 14, 2026
…mic import()

Follow-up to #100/#752. The dynamic-import resolver registered every
target as a regular Import with `is_dynamic: true` and put it in the
eager init chain at program start — functionally correct, but a heavy
locale bundle or optional feature module was paid upfront even when no
dispatch site ever fired.

Reachability classification (compile.rs): fixed-point pass starting from
the entry, propagating Eager across non-type-only static imports and
re-export sources. Everything unmarked is Deferred. Writes the result
to each Module::init_kind (HIR field from #100, no codegen consulted
it before).

Codegen (codegen.rs + expr.rs):
- Entry main filters `deferred_module_prefixes` out of the eager init
  call sequence — Deferred modules only fire from dispatch sites.
- Each non-entry module gains a 3-block wrapper `<prefix>__init` (load
  `@__perry_init_done_<prefix>`, icmp ne 0, cond_br to ret-or-do; do
  block stores 1, calls dep wrappers transitively, then calls
  `<prefix>__init_body`). Existing body code keeps every semantic;
  rename to `_body` is invisible to other code paths.
- `Expr::DynamicImport` (single + multi-path arms) calls
  `<target>__init` before loading `@__perry_ns_<target_prefix>`. For
  Eager targets the guard short-circuits; for Deferred targets it's
  the only invocation that builds the namespace.
- Entry emits a no-op `<entry_prefix>__init` stub so a non-entry
  module dispatching `await import("./entry.ts")` resolves at link.
  The entry's actual body still runs in main; the stub just satisfies
  the dispatch's unconditional init call.

Module init deps (per-module: static-import + re-export sources) are
plumbed to the wrapper's do block so a Deferred module that reaches
another Deferred only through its own re-export chain still initializes
the source before its namespace populator runs.

Cache key hashes deferred_module_prefixes (sorted) and module_init_deps
(ordered) so a program that gains or loses a dynamic-import reachability
path invalidates the entry's cached .o.

Acceptance:
- All 7 dynamic-import gap tests from #100 (literal, ternary, template,
  reexport, tla, cycle, init_time) byte-equal `node --experimental-strip-types`.
- New test_gap_dynamic_import_deferred.ts covers the Deferred-only case:
  marker module's top-level console.log fires only on the dispatch
  branch, never on the no-arg path.
- cargo test --workspace green (excluding cross-host UI crates).

Benchmark (heavy.ts builds a 1M-entry int array at top level,
main.ts dynamically imports it only when argv[2] === 'use'):

|              | PRE-#753              | POST-#753             |
|--------------|-----------------------|-----------------------|
| no-arg       | 8.4 ms ± 1.6 (min 7.4) | 4.8 ms ± 2.2 (min 3.1) |
| use          | 7.6 ms ± 0.4 (min 7.1) | 8.4 ms ± 0.7 (min 7.5) |

hyperfine -N -w 5 -r 30. No-arg branch drops by 43% on mean / 58% on
min — that 4 ms is the for-loop that no longer runs at startup. The
`use` branch is statistically indistinguishable; the heavy init still
runs, just lazily, with one extra call indirection.
TheHypnoo added a commit that referenced this pull request May 14, 2026
…mic import()

Follow-up to #100/#752. The dynamic-import resolver registered every
target as a regular Import with `is_dynamic: true` and put it in the
eager init chain at program start — functionally correct, but a heavy
locale bundle or optional feature module was paid upfront even when no
dispatch site ever fired.

Reachability classification (compile.rs): fixed-point pass starting from
the entry, propagating Eager across non-type-only static imports and
re-export sources. Everything unmarked is Deferred. Writes the result
to each Module::init_kind (HIR field from #100, no codegen consulted
it before).

Codegen (codegen.rs + expr.rs):
- Entry main filters `deferred_module_prefixes` out of the eager init
  call sequence — Deferred modules only fire from dispatch sites.
- Each non-entry module gains a 3-block wrapper `<prefix>__init` (load
  `@__perry_init_done_<prefix>`, icmp ne 0, cond_br to ret-or-do; do
  block stores 1, calls dep wrappers transitively, then calls
  `<prefix>__init_body`). Existing body code keeps every semantic;
  rename to `_body` is invisible to other code paths.
- `Expr::DynamicImport` (single + multi-path arms) calls
  `<target>__init` before loading `@__perry_ns_<target_prefix>`. For
  Eager targets the guard short-circuits; for Deferred targets it's
  the only invocation that builds the namespace.
- Entry emits a no-op `<entry_prefix>__init` stub so a non-entry
  module dispatching `await import("./entry.ts")` resolves at link.
  The entry's actual body still runs in main; the stub just satisfies
  the dispatch's unconditional init call.

Module init deps (per-module: static-import + re-export sources) are
plumbed to the wrapper's do block so a Deferred module that reaches
another Deferred only through its own re-export chain still initializes
the source before its namespace populator runs.

Cache key hashes deferred_module_prefixes (sorted) and module_init_deps
(ordered) so a program that gains or loses a dynamic-import reachability
path invalidates the entry's cached .o.

Acceptance:
- All 7 dynamic-import gap tests from #100 (literal, ternary, template,
  reexport, tla, cycle, init_time) byte-equal `node --experimental-strip-types`.
- New test_gap_dynamic_import_deferred.ts covers the Deferred-only case:
  marker module's top-level console.log fires only on the dispatch
  branch, never on the no-arg path.
- cargo test --workspace green (excluding cross-host UI crates).

Benchmark (heavy.ts builds a 1M-entry int array at top level,
main.ts dynamically imports it only when argv[2] === 'use'):

|              | PRE-#753              | POST-#753             |
|--------------|-----------------------|-----------------------|
| no-arg       | 8.4 ms ± 1.6 (min 7.4) | 4.8 ms ± 2.2 (min 3.1) |
| use          | 7.6 ms ± 0.4 (min 7.1) | 8.4 ms ± 0.7 (min 7.5) |

hyperfine -N -w 5 -r 30. No-arg branch drops by 43% on mean / 58% on
min — that 4 ms is the for-loop that no longer runs at startup. The
`use` branch is statistically indistinguishable; the heavy init still
runs, just lazily, with one extra call indirection.
TheHypnoo added a commit that referenced this pull request May 14, 2026
…mic import() (#768)

Follow-up to #100/#752. The dynamic-import resolver registered every
target as a regular Import with `is_dynamic: true` and put it in the
eager init chain at program start — functionally correct, but a heavy
locale bundle or optional feature module was paid upfront even when no
dispatch site ever fired.

Reachability classification (compile.rs): fixed-point pass starting from
the entry, propagating Eager across non-type-only static imports and
re-export sources. Everything unmarked is Deferred. Writes the result
to each Module::init_kind (HIR field from #100, no codegen consulted
it before).

Codegen (codegen.rs + expr.rs):
- Entry main filters `deferred_module_prefixes` out of the eager init
  call sequence — Deferred modules only fire from dispatch sites.
- Each non-entry module gains a 3-block wrapper `<prefix>__init` (load
  `@__perry_init_done_<prefix>`, icmp ne 0, cond_br to ret-or-do; do
  block stores 1, calls dep wrappers transitively, then calls
  `<prefix>__init_body`). Existing body code keeps every semantic;
  rename to `_body` is invisible to other code paths.
- `Expr::DynamicImport` (single + multi-path arms) calls
  `<target>__init` before loading `@__perry_ns_<target_prefix>`. For
  Eager targets the guard short-circuits; for Deferred targets it's
  the only invocation that builds the namespace.
- Entry emits a no-op `<entry_prefix>__init` stub so a non-entry
  module dispatching `await import("./entry.ts")` resolves at link.
  The entry's actual body still runs in main; the stub just satisfies
  the dispatch's unconditional init call.

Module init deps (per-module: static-import + re-export sources) are
plumbed to the wrapper's do block so a Deferred module that reaches
another Deferred only through its own re-export chain still initializes
the source before its namespace populator runs.

Cache key hashes deferred_module_prefixes (sorted) and module_init_deps
(ordered) so a program that gains or loses a dynamic-import reachability
path invalidates the entry's cached .o.

Acceptance:
- All 7 dynamic-import gap tests from #100 (literal, ternary, template,
  reexport, tla, cycle, init_time) byte-equal `node --experimental-strip-types`.
- New test_gap_dynamic_import_deferred.ts covers the Deferred-only case:
  marker module's top-level console.log fires only on the dispatch
  branch, never on the no-arg path.
- cargo test --workspace green (excluding cross-host UI crates).

Benchmark (heavy.ts builds a 1M-entry int array at top level,
main.ts dynamically imports it only when argv[2] === 'use'):

|              | PRE-#753              | POST-#753             |
|--------------|-----------------------|-----------------------|
| no-arg       | 8.4 ms ± 1.6 (min 7.4) | 4.8 ms ± 2.2 (min 3.1) |
| use          | 7.6 ms ± 0.4 (min 7.1) | 8.4 ms ± 0.7 (min 7.5) |

hyperfine -N -w 5 -r 30. No-arg branch drops by 43% on mean / 58% on
min — that 4 ms is the for-loop that no longer runs at startup. The
`use` branch is statistically indistinguishable; the heavy init still
runs, just lazily, with one extra call indirection.
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.

Dynamic imports: compile-time resolution for statically analyzable import() calls

2 participants