Summary
A dynamic import() of a literal bare npm specifier — e.g. await import('chalk') — returns a rejected promise at runtime even when the package is in perry.compilePackages. Dynamic-import resolution does not route the specifier through the same resolve_import() machinery that static imports use, so a bare package name is never resolved to its compiled-module namespace.
This is the single most common modern dynamic-import pattern: packages that load ESM-only dependencies (chalk v5, node-fetch v3, nanoid, execa, ora) or lazily load optional/peer dependencies (DB drivers pg / mysql2 / better-sqlite3, Pino transports, mailer providers), almost always with a literal specifier inside a conditional or try/catch.
Current behavior
crates/perry/src/commands/compile/collect_modules.rs:465-548 — when const-folding resolves a dynamic-import path string, it classifies it (is_native_module) and pushes an import edge, but with resolved_path: None. It treats the bare string as-is and never runs the file/package resolution that static imports undergo.
crates/perry-codegen/src/expr/dyn_extern_i18n.rs:85 — the dispatch unconditionally looks up ctx.dynamic_import_path_to_prefix[path] expecting a compiled-module prefix with a @__perry_ns_<prefix> global. A bare specifier isn't in that map → fall through to js_promise_rejected(undefined).
Proposed fix
Route the resolved dynamic-import path string through the same resolver static imports use (crates/perry/src/commands/compile/resolve.rs:1344, resolve_import()), so 'chalk' finds its compilePackages copy and gets registered as a real compiled module. Compiled packages already materialize @__perry_ns_<prefix> in their __init, so once the bare name maps to the compiled module prefix in dynamic_import_path_to_prefix, the existing dispatch loads the namespace correctly.
(node: builtins are split out into the sibling issue, since they need native-namespace materialization rather than file resolution.)
Acceptance criteria
const { default: chalk } = await import('chalk') resolves to the package namespace (package listed in perry.compilePackages).
- The optional-dependency idiom behaves per Node:
try { await import('pg') } catch {} resolves when the package is compiled-in, rejects (→ catch) when it is not. The reject-on-miss fallthrough already gives the correct failure semantics; this issue is about fixing the success path.
- Deferred-init semantics preserved: a package reached only via dynamic import still inits lazily on first dispatch.
- New parity/test-files coverage for a literal bare-specifier dynamic import.
Files
crates/perry/src/commands/compile/collect_modules.rs:465-548
crates/perry/src/commands/compile/resolve.rs:1344 (resolve_import)
crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Part of the dynamic-import Node.js-compatibility series (literal-resolution tier).
Summary
A dynamic
import()of a literal bare npm specifier — e.g.await import('chalk')— returns a rejected promise at runtime even when the package is inperry.compilePackages. Dynamic-import resolution does not route the specifier through the sameresolve_import()machinery that static imports use, so a bare package name is never resolved to its compiled-module namespace.This is the single most common modern dynamic-import pattern: packages that load ESM-only dependencies (chalk v5, node-fetch v3, nanoid, execa, ora) or lazily load optional/peer dependencies (DB drivers
pg/mysql2/better-sqlite3, Pino transports, mailer providers), almost always with a literal specifier inside a conditional ortry/catch.Current behavior
crates/perry/src/commands/compile/collect_modules.rs:465-548— when const-folding resolves a dynamic-import path string, it classifies it (is_native_module) and pushes an import edge, but withresolved_path: None. It treats the bare string as-is and never runs the file/package resolution that static imports undergo.crates/perry-codegen/src/expr/dyn_extern_i18n.rs:85— the dispatch unconditionally looks upctx.dynamic_import_path_to_prefix[path]expecting a compiled-module prefix with a@__perry_ns_<prefix>global. A bare specifier isn't in that map → fall through tojs_promise_rejected(undefined).Proposed fix
Route the resolved dynamic-import path string through the same resolver static imports use (
crates/perry/src/commands/compile/resolve.rs:1344,resolve_import()), so'chalk'finds itscompilePackagescopy and gets registered as a real compiled module. Compiled packages already materialize@__perry_ns_<prefix>in their__init, so once the bare name maps to the compiled module prefix indynamic_import_path_to_prefix, the existing dispatch loads the namespace correctly.(
node:builtins are split out into the sibling issue, since they need native-namespace materialization rather than file resolution.)Acceptance criteria
const { default: chalk } = await import('chalk')resolves to the package namespace (package listed inperry.compilePackages).try { await import('pg') } catch {}resolves when the package is compiled-in, rejects (→catch) when it is not. The reject-on-miss fallthrough already gives the correct failure semantics; this issue is about fixing the success path.Files
crates/perry/src/commands/compile/collect_modules.rs:465-548crates/perry/src/commands/compile/resolve.rs:1344(resolve_import)crates/perry-codegen/src/expr/dyn_extern_i18n.rsPart of the dynamic-import Node.js-compatibility series (literal-resolution tier).