feat: closes #100 — compile-time-resolved dynamic import()#752
Merged
Conversation
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.
Contributor
|
Awesome work, will dig in shortly! |
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.
Contributor
Author
|
Sorry about the branch name; it's the name of a work tree I overlooked it |
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #100. Compile-time-resolved dynamic
import()end-to-end: targets are reachable through the resolved namespace, with byte-for-byte parity againstnode --experimental-strip-typeson every parity test.Resolved subset (D1 from the spec)
await import('./foo.ts')await import(flag ? './a.ts' : './b.ts')await import(`./locale_${lang}.ts`)crates/perry-hir/src/dynamic_import.rs)Acceptance (byte-for-byte vs Node, 7 parity tests)
42\nhello from a42\nhello from a42\nb42\nbholahola7799\ntla-loaded99\ntla-loadeda-exporta-export1717Implementation highlights
feat(runtime): js_create_namespace— builds the module-namespace object from parallel key/value arrays. Returnsf64(NaN-boxed POINTER_TAG) to match the LLVM ABI.feat(hir): flatten_exports— resolvesExportAll/ReExport/NamespaceReExportthrough 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>(ormainfor 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 ajs_string_equalschain over each compile-time path with ajs_promise_rejectedfallthrough.feat(hir): template literals + const-propagated locals— resolver followsBinary(Add, ...)chains (Cartesian product) andLocalGet(x)for module-level non-mutated consts.Notes on semantics
export letslots; this PR materializes a snapshot at__perry_init_<prefix>end. For Perry workloads (mostlyexport const/function/class) this is indistinguishable. Documented divergence; live bindings can be added later via indirection injs_create_namespace's setter slots.awaitblocks synchronously inside__perry_init_<prefix>, so by the time the dispatcher reads__perry_ns_<prefix>the post-TLA exports are already populated. Verified bytest_gap_dynamic_import_tla.ts.topo_visitsilently breaks the dynamic back-edge, and re-entry into A's init does not occur. Verified bytest_gap_dynamic_import_cycle.ts.await import(...)at module init time works because the dynamic edge is registered incollect_modulesas a regular import, putting the target ahead of the consumer in the topological order. Verified bytest_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_kindis already populated in this PR; #753 wires it through codegen (skip eager call for Deferred modules + dispatch-side__initcall + idempotent guard). Pure perf; the current behavior here is functionally correct.