Skip to content

Implement static import defer (TC39 Stage 3)#30975

Open
robobun wants to merge 11 commits into
mainfrom
farm/20aa82d8/import-defer
Open

Implement static import defer (TC39 Stage 3)#30975
robobun wants to merge 11 commits into
mainfrom
farm/20aa82d8/import-defer

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 18, 2026

Implements the static form of the Stage 3 Deferred Module Evaluation proposal:

import defer * as ns from "./mod.js";
// mod.js is fetched and linked now, but NOT evaluated.

console.log("before");
console.log(ns.value);   // <-- mod.js evaluates here, synchronously

JavaScriptCore already carries the full runtime for this (landed upstream in WebKit/WebKit#64053 and present in Bun's pinned WebKit 3167a44fb9). This PR threads the ModulePhase::Defer flag through Bun's own parser → printer → JSModuleRecord bridge so JSC actually receives it, and flips on useImportDefer.

Supersedes #30015, which was written before the WebKit bump and gated everything behind #ifdef JSC_HAS_IMPORT_DEFER pending a separate oven-sh/WebKit patch. That indirection is no longer needed. This PR is also narrower in scope — it intentionally does not implement dynamic import.defer().

What changed

Parser / ASTsrc/js_parser/parse/parse_stmt.rs, src/ast/{s,import_record}.rs

  • defer is a contextual phase keyword only when immediately followed by *. In every other position (import defer from "x", import defer, {a} from "x", import { defer } from "x") it remains an ordinary identifier.
  • New S::Import.phase_defer and ImportRecord::Flags::PHASE_DEFER.

Printer / ModuleInfosrc/js_printer/lib.rs

  • Emits import defer so JSC's own parser sees it during bytecode generation.
  • New RecordKind::ImportInfoNamespaceDefer; requested_modules now dedups by (specifier, phase) — matching JSC's ModuleAnalyzer::appendRequestedModule — and serialises a parallel u8 phase array. Transpiler-cache version bumped (20 → 21) for the format change.
  • finalize()'s local→indirect export rewrite intentionally ignores deferred namespace imports so import defer * as ns; export { ns } stays a local export, matching ModuleAnalyzer::exportVariable.

JSC bridgesrc/bundler/analyze_transpiled_module.rs, src/bundler_jsc/analyze_jsc.rs, src/jsc/bindings/BunAnalyzeTranspiledModule.cpp

  • appendRequestedModule(..., ModulePhase::Defer) and addImportEntry({ Namespace, phase: Defer, ... }) for deferred requests.
  • dumpRecordInfo includes phase so the BUN_DEBUG fallback-parse diff stays in agreement.

Runtimesrc/jsc/bindings/ZigGlobalObject.cpp

  • JSC::Options::useImportDefer() = true.

Unrelated build fixsrc/jsc/bindings/wtf-bindings.cpp

  • wtf/Int128.h dropped its transitive <cassert> in the latest WebKit bump, which was the only thing declaring assert() for uv__tty_make_raw() in that unified-build unit. Added the include directly.

Tests (test/js/bun/resolve/import-defer.test.ts, 16 cases)

  • Evaluation deferred until first non-symbol property access; no re-eval on subsequent access
  • ns[Symbol.toStringTag] === "Deferred Module" without triggering evaluation
  • in / Object.keys trigger evaluation
  • Throwing module re-throws the same error on every access
  • Same specifier imported at both Evaluation and Defer phase
  • Transitive TLA dep evaluated eagerly (GatherAsynchronousTransitiveDependencies) so on-access eval stays synchronous
  • import defer * as ns; export { ns } exports the deferred namespace as a local binding
  • import defer * as d from "./x.json" with { type: "json" }
  • Works in .ts
  • defer still a valid identifier (import defer from, import defer, {x} from, import { defer } from)
  • Bun.Transpiler round-trips import defer
  • import defer { x } from / import defer x from are syntax errors

Out of scope

  • Dynamic import.defer(...) (left for a follow-up; JSC's upstream PR also excluded it).
  • Bundler-side lazy-wrapping: when bun build inlines a deferred dependency into the same chunk the defer semantics are lost (module body runs at chunk load). External imports (--external) correctly preserve import defer in the output.

References

robobun added 2 commits May 18, 2026 06:27
wtf/Int128.h dropped its <cassert> include in the latest WebKit bump,
which was the only thing declaring assert() for uv__tty_make_raw() in
the unified build.
Implements the static form of the Stage 3 Deferred Module Evaluation
proposal. The module graph is loaded and linked eagerly; evaluation of
the deferred module (and its synchronous dependencies) is postponed
until a non-symbol property of the namespace object is accessed.

JavaScriptCore already carries the full runtime semantics in the
pinned WebKit (ModulePhase::Defer, deferred namespace objects,
GatherAsynchronousTransitiveDependencies). This change threads the
phase through Bun's pipeline:

- js_parser: recognise `defer` as a contextual phase keyword when
  followed by `*`; `import defer from "x"` and `import defer, {x}`
  keep treating `defer` as an ordinary default-binding identifier.
- S::Import / ImportRecord: carry a phase_defer bit.
- js_printer: emit `import defer` back out so JSC's bytecode parser
  sees it; record ImportInfoNamespaceDefer and a per-request phase in
  ModuleInfo.
- ModuleInfo wire format: dedup requested modules by (specifier, phase)
  to match ModuleAnalyzer, serialise a parallel phase-byte array, bump
  the transpiler-cache version.
- BunAnalyzeTranspiledModule: pass ModulePhase::Defer to
  appendRequestedModule() and set ImportEntry.phase on the deferred
  namespace import entry; include phase in the debug record diff.
- ZigGlobalObject: enable JSC::Options::useImportDefer.

Re-exporting a deferred namespace (`import defer * as ns; export {ns}`)
stays a local export, matching ModuleAnalyzer::exportVariable.

Dynamic `import.defer()` is intentionally out of scope.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 18, 2026

Updated 2:28 AM PT - May 19th, 2026

@robobun, your commit 6307ae8 is building: #56044

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Implement TC39 Stage 3 import defer #30015 - Earlier open attempt at implementing the same TC39 Stage 3 import defer feature, explicitly superseded by this PR

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds TC39 "import defer * as ns from 'path'" support: parser recognizes the syntax, AST gains phase flags, printer and binary format track per-request phase bytes, bundler/JSC wiring and FFI propagate phase_defer to runtime module registration, runtime option and cache bumped, and tests added.

Changes

Import Defer Feature

Layer / File(s) Summary
AST extensions and bitflags
src/ast/import_record.rs, src/ast/s.rs
New PHASE_DEFER bitflag and Import::phase_defer: bool mark deferred namespace imports.
Parser recognition and statement construction
src/js_parser/parse/parse_stmt.rs, src/js_parser/p.rs, src/js_parser/parse/parse_entry.rs, src/js_parser/lower/lower_esm_exports_hmr.rs
Parser recognizes import defer * as ns from 'path', sets phase_defer: true for parsed defer namespace imports, and initializes phase_defer:false on synthetic/generated imports; import-record flags record the phase.
Printer phase tracking and ModuleInfo serialization
src/js_printer/lib.rs
Introduce ModulePhase, phase-aware RequestedModules, store/serialize requested_modules_phases, emit defer in printed imports, and request modules with explicit phases.
Bundler ModuleInfo (printer↔bundler) interop
src/bundler/analyze_transpiled_module.rs
Re-export ModulePhase, add IMPORT_INFO_NAMESPACE_DEFER record kind, parse/serialize phases region with alignment and validation, and expose deserialized phases.
Bundler JSC decode and JSModuleRecord wiring
src/bundler_jsc/analyze_jsc.rs
Decode phase bytes, validate parallel arrays, map to phase_defer booleans, and pass phase_defer into requested-module registration and deferred namespace import emission.
JSC FFI bindings and runtime initialization
src/jsc/bindings/BunAnalyzeTranspiledModule.cpp, src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/wtf-bindings.cpp, src/jsc/RuntimeTranspilerCache.rs
FFI signatures extended to accept phaseDefer; added JSC_JSModuleRecord__addImportEntryNamespaceDefer; debug dumps print phase; useImportDefer enabled; cache version bumped to 21.
Test suite and validation
test/js/bun/resolve/import-defer.test.ts
Comprehensive tests for deferred evaluation timing, error propagation, import attributes, TS/transpiler handling, identifier parsing, and syntax errors.

Suggested reviewers

  • alii
  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly identifies the main feature: implementing the static form of TC39's Deferred Module Evaluation proposal.
Description check ✅ Passed The pull request description comprehensively covers what the PR does, how it was verified, implementation details, test coverage, and scope limitations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bundler_jsc/analyze_jsc.rs`:
- Around line 100-108: The loop zips requested_modules_keys,
requested_modules_values and requested_modules_phases but only uses
debug_assert_eq!, so in release a short phases buffer will truncate items or
accept invalid phase bytes; before the zip (around the loop using
requested_modules_keys/requested_modules_values/requested_modules_phases and the
phase_defer calculation), validate that requested_modules_keys.len() ==
requested_modules_values.len() == requested_modules_phases.len() and that every
byte in requested_modules_phases is either 0 or 1; if these checks fail, return
or propagate a clear error (do not rely on debug asserts) to reject malformed
phase data rather than silently truncating or mis-tagging module phases.

In `@src/bundler/analyze_transpiled_module.rs`:
- Around line 362-364: Add a debug assertion that the length of the bytes
returned by self.requested_modules_phases() matches the number of rm_keys before
writing them: compute let phases = self.requested_modules_phases(); then
debug_assert_eq!(phases.len(), rm_keys.len(), "requested_modules_phases() length
must match rm_keys"); finally write phases with writer.write_all(&phases)? and
keep the existing pad calculation based on rm_keys.len().

In `@src/js_parser/lower/lower_esm_exports_hmr.rs`:
- Line 564: The deduplication currently hardcodes phase_defer: false which
collapses deferred imports into eager ones; update the dedup logic to carry the
original import's phase_defer flag through (use the existing phase_defer field
rather than false) and ensure callers pass the flag (notably change calls that
construct deduped imports such as SImport(st) to forward st.phase_defer into the
dedup/new import creation). Locate the deduplication site in
lower_esm_exports_hmr.rs and replace the literal false with the propagated
phase_defer variable, and update all call sites that create deduped imports to
accept and forward the phase_defer value.

In `@src/js_printer/lib.rs`:
- Around line 494-509: rename_key currently only updates self.keys and rebuilds
self.index, which leaves duplicate rows in self.keys/self.values/self.phases
when replace_string_id collapses distinct specifiers to the same (StringID,
phase); change rename_key (or callsite after replace_string_id) to
re-deduplicate requested modules: when touched is true, iterate through
self.keys/self.values/self.phases in order, keep the first occurrence for each
(key, phase) pair (use a temporary HashSet of seen (StringID, phase)), push
unique entries into new_keys/new_values/new_phases, then replace the originals
and rebuild self.index from those deduped vectors so the (specifier, phase)
invariant holds.

In `@test/js/bun/resolve/import-defer.test.ts`:
- Around line 36-44: Replace the unconditional stderr assertions with the
house-style conditional check in every test that expects exitCode.toBe(0):
before the expect(exitCode).toBe(0) line, add if (exitCode !== 0) {
expect(stderr).toBe(""); } and remove or replace the existing
expect(stderr).toBe(""); lines; update each test in this file that currently
asserts exitCode === 0 (they use stdout, stderr, exitCode variables) so stderr
is only asserted conditionally to surface stderr on failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0a798af-da57-4025-84c5-41f5505ee763

📥 Commits

Reviewing files that changed from the base of the PR and between b36759f and b8e5f91.

📒 Files selected for processing (14)
  • src/ast/import_record.rs
  • src/ast/s.rs
  • src/bundler/analyze_transpiled_module.rs
  • src/bundler_jsc/analyze_jsc.rs
  • src/js_parser/lower/lower_esm_exports_hmr.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/bindings/BunAnalyzeTranspiledModule.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/wtf-bindings.cpp
  • test/js/bun/resolve/import-defer.test.ts

Comment thread src/bundler_jsc/analyze_jsc.rs Outdated
Comment thread src/bundler/analyze_transpiled_module.rs Outdated
Comment thread src/js_parser/lower/lower_esm_exports_hmr.rs
Comment thread src/js_printer/lib.rs
Comment thread test/js/bun/resolve/import-defer.test.ts
Reject the deserialized module-info record in release builds if the
three requested-module arrays have mismatched lengths or a phase byte
is neither Evaluation (0) nor Defer (1). The buffer can originate from
the on-disk transpiler cache, so this matches the existing treatment
of unknown RecordKind bytes.
Comment thread src/js_parser/parse/parse_stmt.rs Outdated
Comment thread src/bundler/analyze_transpiled_module.rs
Compare the raw token bytes (not the decoded identifier) when
recognising `import defer`, so escape sequences cannot spell the
phase keyword — matching the treatment of `as`/`from`/`async`.

Also fix the stale "six views" comment in
ModuleInfoDeserialized::create().
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/parse/parse_stmt.rs`:
- Around line 1481-1497: The defer-phase branch handling `if default_name_raw ==
b"defer" && p.lexer.token == T::TAsterisk` allows `import defer * as ...`
without enforcing the same scope restrictions as regular namespace imports; add
the same gating logic used for regular imports (check `is_module_scope ||
(is_namespace_scope && is_typescript_declare)`) before accepting the defer-star
import, and reject or error out in the same way as the non-defer branch if the
scope check fails; update the block around `p.lexer.next()`,
`p.lexer.expect_contextual_keyword(b"as")`, and the eventual `return
p.process_import_statement(stmt, path, loc, false)` to run only when the scope
check passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bc1918f6-48f8-4018-9890-7ea34a43054e

📥 Commits

Reviewing files that changed from the base of the PR and between 34c4f6b and daced86.

📒 Files selected for processing (3)
  • src/bundler/analyze_transpiled_module.rs
  • src/js_parser/parse/parse_stmt.rs
  • test/js/bun/resolve/import-defer.test.ts

Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs
Comment thread src/js_parser/parse/parse_stmt.rs Outdated
Comment thread test/js/bun/resolve/import-defer.test.ts Outdated
Apply the same scope gate as regular namespace imports: the
declaration is only valid at module scope (or inside a TypeScript
`declare namespace`). Previously `namespace X { import defer * as ns
from "y" }` slipped through because the defer branch returned
early before the TIdentifier arm's namespace-scope fall-through to
parse_type_script_import_equals_stmt.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/parse/parse_stmt.rs`:
- Around line 1481-1506: The early branch treating `default_name_raw == b"defer"
&& p.lexer.token == T::TAsterisk` as an ESM defer-import should also check
`!opts.is_export` so `export import defer * as ...` does not take the ESM path;
update the if condition that contains `default_name_raw == b"defer" &&
p.lexer.token == T::TAsterisk` (inside the parse/import handling where `stmt =
S::Import { ... phase_defer: true, ... }`) to include `&& !opts.is_export`,
ensuring export-import-equals cases fall through to the TypeScript import-equals
handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: deff69b8-b4e9-4ee2-9fb4-9b1280459c6d

📥 Commits

Reviewing files that changed from the base of the PR and between daced86 and baef48d.

📒 Files selected for processing (2)
  • src/js_parser/parse/parse_stmt.rs
  • test/js/bun/resolve/import-defer.test.ts

Comment thread src/js_parser/parse/parse_stmt.rs Outdated
robobun added 2 commits May 18, 2026 07:40
…mport trimming

When the deferred namespace was only referenced from a dead branch,
scan_imports would clear `star_name_loc` (both in the dead-code
trimmer and in the star-to-clause converter). That left the printer
with `PHASE_DEFER` but no `CONTAINS_IMPORT_STAR`, producing
`import defer"./x";` — a hard SyntaxError in JSC.

Keep the `* as ns` binding for phase-defer imports so the module
stays linked-but-unevaluated (the intended semantics), and as a
belt-and-suspenders measure gate the printed `defer` token on
`CONTAINS_IMPORT_STAR` too.

Also: make the test suite concurrent; fix a stale '0..=8' comment.
`export import` in TypeScript is the import-equals form
(`export import X = Y.Z`); the defer branch was returning early
before the `opts.is_export` fall-through to
parse_type_script_import_equals_stmt, silently dropping the
`export` and parsing it as a plain deferred import.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Thanks — all four points from my last pass are addressed (scan-side phase_defer guard + printer belt-and-suspenders, scope/is_export checks with tests, stale comment, describe.concurrent). Nothing further from me; deferring final sign-off to a human given this threads a new language feature through parser → serialized ModuleInfo (cache format bump) → Rust/C++ FFI.

Extended reasoning...

Overview

Implements TC39 Stage 3 import defer * as ns from across 15 files: parser recognition (parse_stmt.rs), AST flag (S::Import.phase_defer, ImportRecordFlags::PHASE_DEFER), printer emission + ModuleInfo phase tracking with a new RecordKind and parallel u8 phase array, on-disk serialization changes in analyze_transpiled_module.rs (cache version 20→21), Rust↔C++ FFI signature extensions in analyze_jsc.rs / BunAnalyzeTranspiledModule.cpp, and JSC::Options::useImportDefer() = true. 19 tests cover evaluation timing, error semantics, TLA interaction, identifier-vs-keyword, and the syntax-error edge cases raised in review.

Prior review follow-up

Since my 07:22 review, commits 810e569 / baef48d / bfdda47 landed:

  • Unused-import trimming (the 🔴 item): scan_imports.rs now skips stripping star_name_loc and the star→clause conversion when st.phase_defer; the printer additionally gates the defer token on CONTAINS_IMPORT_STAR. New test "namespace only referenced in dead code keeps the deferred binding" covers it.
  • Scope / export import: defer branch now guarded by !opts.is_export and the same is_module_scope / is_namespace_scope / is_typescript_declare check as the T::TAsterisk arm; two new syntax-error tests.
  • Stale 0..=8 comment: rephrased to "out-of-range bytes are invalid".
  • describe.concurrent: applied.
    All bot threads (mine and CodeRabbit's) are resolved or addressed.

Security risks

None identified. No auth/crypto/permissions surface; the deserialization path validates phase bytes and array lengths in release builds (added in 34c4f6b), returning null_mut() on malformed cache data rather than trusting it.

Level of scrutiny

High — warrants human review. This is a new language-level feature touching production-critical code paths: the JS parser (correctness of contextual-keyword handling), the printer's ModuleInfo binary format that is persisted to the transpiler cache, and FFI signatures that must agree byte-for-byte between Rust and C++ (extern "C" with appended bool phaseDefer). The serialization-format change and the global useImportDefer option flip both have blast radius beyond the immediate diff.

Other factors

Test coverage is solid and targeted at the spec semantics plus every regression raised in review. The implementation looks correct to me after re-reading the updated diff, but the combination of FFI ABI changes, on-disk format changes, and parser grammar additions is the kind of change a maintainer should sign off on.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

All my earlier feedback has been addressed and I found nothing new on re-review — but given this threads a new phase flag through parser → printer → ModuleInfo serialization → FFI → JSModuleRecord and bumps the on-disk cache format, it's worth a human sign-off.

Extended reasoning...

Overview

This PR implements static import defer * as ns from "..." (TC39 Stage 3) end-to-end: parser recognition (parse_stmt.rs), AST/import-record flags (s.rs, import_record.rs), printer emission and ModuleInfo phase tracking (js_printer/lib.rs), binary (de)serialization with a new parallel u8 phase array and 4-byte padding (analyze_transpiled_module.rs, js_printer/lib.rs), the Rust→C++ FFI bridge to JSModuleRecord with widened signatures (analyze_jsc.rs, BunAnalyzeTranspiledModule.cpp), enabling JSC::Options::useImportDefer, a transpiler-cache version bump (20 → 21), and a 384-line test suite covering evaluation timing, error propagation, TLA, re-exports, attributes, identifier disambiguation, and the syntax-error edge cases that came up during review.

Security risks

None apparent. No auth/crypto/permissions surface. The deserialization path for the new phase byte (which can come from the on-disk transpiler cache) validates lengths and rejects bytes outside {0,1} in release builds (added in 34c4f6b after CodeRabbit's note), so a corrupted cache entry fails closed rather than mis-tagging modules. The cache version bump prevents old-format records from being misread.

Level of scrutiny

High. This is a new language feature touching the hot module-loading path: parser grammar, printer output that JSC re-parses, a binary wire format consumed across an FFI boundary, and C++ extern "C" signature changes that must agree with the Rust declarations. A mismatch in any of these layers would break module loading or silently change evaluation order. The finalize() local→indirect export rewrite and the RequestedModules dedup-by-(specifier, phase) change are subtle correctness points that benefit from review by someone familiar with the JSC ModuleAnalyzer semantics this is mirroring.

Other factors

  • I left six inline comments across two earlier review passes (escape-spelled def\u0065r, stale "six views" / "0..=8" comments, the TS unused-import-trimming → invalid import defer"./x"; printer bug, the TS namespace / export import defer scope-guard gaps, and describe.concurrent). All were addressed in daced86, 810e569, baef48d, and bfdda47 with regression tests added for each.
  • All CodeRabbit threads are resolved (two accepted, three declined with rationale the bot accepted).
  • The two CI failures (node-http-backpressure, v8-heap-snapshot SIGKILLs) are unrelated flakes.
  • Test coverage is thorough for the runtime path; the explicitly out-of-scope bundler/HMR degradation is documented.

This is well-executed and I have no outstanding concerns, but it is not the kind of mechanical change I should approve without a human reviewer.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 18, 2026

CI status

Build #56044 at 6307ae8a2f: 71 pass / 1 fail — the only failure is one darwin-14-x64-test-bun shard where test/js/bun/s3/s3.test.ts timed out because the Docker daemon wasn't running on the agent (ERROR: Cannot connect to the Docker daemon at unix:///var/run/docker.sock). Unrelated infrastructure issue.

On that same failing shard, test/bundler/bundler_compile.test.ts (including the new compile/ImportDeferBytecode+{cjs,esm} tests) passed 56/56. test/js/bun/resolve/import-defer.test.ts passes on every lane that runs it.

Already spent the one re-roll earlier, so handing off to a maintainer to re-run the darwin-x64 shard or merge over it. The diff is green.

sosukesuzuki
sosukesuzuki previously approved these changes May 18, 2026
Comment thread src/ast/s.rs
/// "import defer * as ns from 'path'" — the TC39 Deferred Module Evaluation
/// proposal. Only valid with a namespace import (`star_name_loc` is set,
/// `default_name`/`items` are empty).
pub phase_defer: bool, // = false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we turn this into an enum of phases? to support source phase later

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reasonable — I went back and forth on this. A few notes on why I landed on the bool here but kept the enum where it matters for forward-compat:

  • The serialized ModuleInfo already uses a ModulePhase enum (Evaluation = 0, Defer = 1) for the per-requested-module phase byte, so the wire format is trivially extensible to Source = 2 without another cache-version bump.
  • import source x from "..." has a different grammar shape (default binding only, no * as), so it wouldn't share this S::Import branch in parse_stmt anyway — it'd get its own handling and probably its own S::Import field (or a shared phase: ImportPhase at that point).
  • ImportRecord::Flags is now full (u16, bit 15 is PHASE_DEFER). Adding PHASE_SOURCE would mean widening to u32 or moving phase to a separate u8 on the record — either is fine but felt like it belonged with the change that actually needs it.

Happy to switch phase_defer: boolphase: ImportPhase { Evaluation, Defer } on S::Import now if you'd rather have it in place — it's a small mechanical change. Let me know.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pretty reasonable - how can we make sure we are not introducing strange bugs by theoretically allowing an invalid state though?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The invariant (phase_deferstar_name_loc.is_some()default_name.is_none()items.is_empty()) is held by construction right now — the only place that sets phase_defer: true is the parser branch that builds a fresh S::Import { star_name_loc: Some(_), ..Default::default() }, and every other S::Import constructor (7 of them) explicitly passes phase_defer: false.

For defence-in-depth:

  • Printer already gates the defer token on PHASE_DEFER && CONTAINS_IMPORT_STAR, so even a malformed S::Import can't produce import defer"./x";.
  • scan_imports refuses to strip the star binding when phase_defer is set.
  • Added a debug_assert! in process_import_statement (1a98bc5) that checks the full shape invariant at the S::Import → import-record boundary, so any future producer that violates it trips immediately in debug builds rather than surfacing as odd output downstream.

An enum wouldn't actually prevent the cross-field invalid state (you could still have phase: ImportPhase::Defer with default_name: Some(_)), so the assert is the more direct guard. When import source lands and needs its own shape invariant (default-binding-only), that's the natural point to lift phase_defer: boolphase: ImportPhase and widen the record flags.

phase_defer implies star-only (no default binding, no named clause).
The parser guarantees this by construction; the debug assertion makes
the invariant explicit at the S::Import -> ImportRecord boundary so
any future producer that violates it is caught immediately.
sosukesuzuki
sosukesuzuki previously approved these changes May 19, 2026
Copy link
Copy Markdown
Collaborator

@Jarred-Sumner Jarred-Sumner left a comment

Choose a reason for hiding this comment

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

Add a test for what happens when bytecode compilation is used

Verifies that `import defer * as ns from "..."` in the source
bytecode-compiles cleanly under both --format=cjs and --format=esm,
and that the compiled binary loads from the bytecode cache. The
bundler inlines the deferred module into the entry chunk (documented
limitation), so the test asserts the inlined-evaluation order.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 19, 2026

Added --compile --bytecode tests in 6307ae8 (test/bundler/bundler_compile.test.ts, compile/ImportDeferBytecode+{cjs,esm}). Both formats bytecode-compile cleanly and the compiled binary reports [Disk Cache] Cache hit for sourceCode under BUN_JSC_verboseDiskCache=1, confirming the cached bytecode is actually used.

Since the bundler inlines the deferred module into the entry chunk (documented limitation), the test asserts the inlined-evaluation order — the point is that import defer in source doesn't break bytecode generation or loading.

@robobun robobun requested a review from Jarred-Sumner May 19, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants