Skip to content

Keep unloaded packages out of import() graphs and reject those imports - #1526

Open
cramforce wants to merge 3 commits into
mainfrom
opt/npm-unloaded-dynamic
Open

cramforce wants to merge 3 commits into
mainfrom
opt/npm-unloaded-dynamic

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1458.

Summary

A project marks packages it never loads in its package.json, as "scriptc": { "packages": { "<name>": "unloaded" } }. require() and static imports of such a package fail with Node's MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND, exactly as when the package isn't installed, and the package never joins the program. Two shapes escaped that:

  • A workspace package marked unloaded still resolved to its sources, so an import() of it compiled the package and its whole dependency graph. The workspace resolver now treats an unloaded name as missing. The import() rejects with Node's ERR_MODULE_NOT_FOUND ("Cannot find package '' imported from ").
  • A module that only import() loads, whose static import names a package Node can't resolve (unloaded or absent), failed the build with SC1010. Node resolves that module's static graph when the import() runs, and rejects the promise before any module of the graph evaluates. Preflight now runs its existing resolution walk from each module reached only through a literal import(). When the walk finds a refusal, that import() rejects with Node's message and code, and the refused graph's modules never join the compiled graph or the npm selection. The refusals inside that graph no longer fail the build.

The rejection happens on a microtask after the call, as with any import(). Code that reads the namespace after await still compiles, typed dynamically, because the namespace is never produced. Refusals in the startup graph keep their behavior: the program compiles to Node's startup crash.

Evidence

  • New npm case unloaded-dynamic-import. Both packages are missing under Node, which is the oracle:

    • an import() of an unloaded workspace package, with its namespace destructured;
    • an import() of a local module whose static import names an unloaded package.

    Each rejects with ERR_MODULE_NOT_FOUND and Node's message, and the local module's body never runs. Output is byte-identical to Node's.

  • package-shims.test.ts has a new test that installs both packages: the workspace package linked into node_modules, and an ES module package. Node loads both, which is the control. The compiled program's output is unchanged from the missing-package run.

  • Before this change, the same program failed to build with SC1010, and an installed unloaded workspace package was compiled and run.

  • The existing unloaded-optional-dependency, dynamic-import, dynamic-import-local, workspace-* and workspace-dynamic-import-deps npm cases pass. The npm, npm-static and package-shims suites and the frontend unit tests pass 1106 of 1107; the one failure, node24-url-authority, comes from the sandbox's Node 24.15 versus the pinned 24.22.

  • Zero-cost gate against Compile polyfill packages that are Node builtins as the builtins #1458's tip (8a40fb5, before its readable-stream 3.x commit, which this branch also carries): PASS. All 2502 programs (bench, self, corpus) are byte-identical; bench geomean is −0.18%.

  • The self-hosted compiler builds (identical in the gate), and self-hosting-native-driver.test.ts passes (1155 s).

  • pnpm lint, pnpm format:check and test:ts7 --baselines-only pass. Re-recording all order baselines adds only the new cases' entries.

🤖 Generated with Claude Code

cramforce and others added 3 commits October 20, 2026 07:10
The project's "scriptc": { "packages": { name: "unloaded" } } map covered
require() and static imports of installed packages. Two shapes escaped it:

- A workspace package marked unloaded still resolved to its sources, so an
  import() of it compiled the package and its whole dependency graph. The
  workspace resolver now treats it as missing, and the import() rejects
  with Node's ERR_MODULE_NOT_FOUND.
- A module only import() loads whose static import names a package Node
  cannot resolve (unloaded or absent) failed the build with SC1010. Node
  resolves that graph when the import() runs and rejects its promise
  before any module of it evaluates. Preflight now walks each such root's
  static graph; a refused graph becomes that rejection, with Node's
  message and code, and its modules never join the compiled graph or the
  npm selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 3:17pm UTC

const root =
resolveImport7(program, sf, arg.text) ?? npmStaticDepSf7(program, sf, arg.text);
if (root === null || root.isDeclarationFile || startup.has(root)) continue;
if (dynamicImportRejections.has(root) || covered.has(root)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a literal import() targets a failing dependency of an already-rejected import graph, covered.has(root) skips its rejection entry, so the dependency is compiled and can fail lowering instead of rejecting at runtime.

Fix on Vercel

@cramforce
cramforce requested a review from ctate October 20, 2026 16:15
Base automatically changed from opt/npm-polyfill-shims to main October 20, 2026 23:28

This branch was successfully deployed

1 active deployment
Preview — 7a3d6fd1 Deployed Oct 20, 2026 by vercel[bot]
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.

1 participant