Skip to content

Fix TLA sibling dynamic-import TDZ regression (#30634)#30639

Closed
robobun wants to merge 2 commits into
mainfrom
farm/cb50e83f/fix-tla-sibling-dynamic-import
Closed

Fix TLA sibling dynamic-import TDZ regression (#30634)#30639
robobun wants to merge 2 commits into
mainfrom
farm/cb50e83f/fix-tla-sibling-dynamic-import

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

@lexical/react 0.44+ (and many packages that dispatch dev/prod entries via await import() in a wrapper module) throws a TDZ error when imported concurrently:

import("@lexical/react/LexicalComposer");
// ReferenceError: Cannot access 'HISTORY_MERGE_TAG' before initialization
//   at node_modules/@lexical/react/LexicalComposer.dev.mjs:47:8

Minimal repro:

// wrapper.mjs — same shape as lexical/Lexical.node.mjs
const mod = await import("./inner.mjs");
export const FOO = mod.FOO;
export const BAR = mod.BAR;

// consumer1.mjs
import { FOO } from "./wrapper.mjs";

// consumer2.mjs
import { BAR } from "./wrapper.mjs";

// entry.mjs
await Promise.all([import("./consumer1.mjs"), import("./consumer2.mjs")]);

Bun throws ReferenceError: Cannot access BAR before initialization at consumer2.

Root cause

Bun-specific skip at innerModuleEvaluation step 11.c.v (in vendor/WebKit/Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp) fires based on three conditions — depWasAlreadyEvaluatingAsync, asyncEvaluationOrder < asyncOrderWatermark, pendingAsyncDependencies == 0 — that are indistinguishable between:

  1. Self-deadlock (skip correct): TLA A awaits import(B); B statically imports A.
  2. Sibling race (skip wrong, this bug): two independent dynamic imports via Promise.all share a TLA wrapper. Skipping runs consumers too early → TDZ.

No passive signal at the decision point distinguishes the two.

Fix

Drops the custom skip in oven-sh/WebKit#228 and bumps WEBKIT_VERSION here.

Coordination

Mirrors the oven-sh/WebKit#215#30262 pattern: WebKit PR merges first, then this WEBKIT_VERSION gets updated to the merged SHA (currently pointing at preview-pr-228-3a85cc44 preview build). Bun CI stays red until that preview build publishes its tarball.

Tests

test/js/bun/resolve/dynamic-import-tla-cycle.test.ts:

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3929f65d-1875-4a9c-abcc-f5098cf9cf82

📥 Commits

Reviewing files that changed from the base of the PR and between c28225a and 4c57af9.

📒 Files selected for processing (1)
  • scripts/build/deps/webkit.ts

Walkthrough

WebKit build version is bumped to a preview identifier, and test coverage for dynamic-import and top-level-await cycles is refactored: two concrete deadlock tests become placeholders, and a new concurrent-import test validates that sibling imports with TLA and reexports execute safely.

Changes

WebKit and TLA Test Updates

Layer / File(s) Summary
WebKit version update
scripts/build/deps/webkit.ts
WEBKIT_VERSION constant changed from a commit hash to preview identifier preview-pr-228-3a85cc44, altering the default prebuilt tarball selection.
Dynamic-import TLA cycle test refactoring
test/js/bun/resolve/dynamic-import-tla-cycle.test.ts
Two previously-concrete deadlock-safety tests replaced with test.todo placeholders; new test added for concurrent dynamic imports via Promise.all with TLA wrapper and reexports, verifying no stderr, correct export values, and clean exit.

Possibly Related Issues

Possibly Related PRs

  • oven-sh/bun#30262: Both PRs bump WEBKIT_VERSION to the same preview identifier and update dynamic-import-tla-cycle.test.ts for TLA sibling/import ordering coverage.

Suggested Reviewers

  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a TLA sibling dynamic-import TDZ regression, matching the core issue addressed by updating WebKit version and modifying the test.
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.
Description check ✅ Passed The PR description comprehensively covers the problem, root cause, fix, coordination strategy, and test changes with detailed technical context and minimal repro examples.

✏️ 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.

@coderabbitai coderabbitai Bot left a comment

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.

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 `@scripts/build/deps/webkit.ts`:
- Line 6: The WEBKIT_VERSION constant currently points to a non-existent preview
release ("preview-pr-228-3a85cc44") causing 404s when downloading artifacts; fix
by either creating the matching release/tag and uploading the required platform
artifacts in the oven-sh/WebKit repo, or update the WEBKIT_VERSION value to a
known existing release tag (replace the string in the WEBKIT_VERSION export)
that contains the prebuilt artifacts you need; ensure the chosen tag is
reachable and contains assets for all target platforms so the build can download
successfully.
🪄 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: bdb2e52a-2353-47a5-accb-86e79eb8f5b3

📥 Commits

Reviewing files that changed from the base of the PR and between b9c757b and c28225a.

📒 Files selected for processing (2)
  • scripts/build/deps/webkit.ts
  • test/js/bun/resolve/dynamic-import-tla-cycle.test.ts

* From https://github.com/oven-sh/WebKit releases.
*/
export const WEBKIT_VERSION = "5488984d20e0dbfe4be2c3ba8fb18eb81a5e0e8b";
export const WEBKIT_VERSION = "preview-pr-228-3a85cc44";

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.

⚠️ Potential issue | 🔴 Critical | ⚖️ Poor tradeoff

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the WebKit preview release and upstream PR exist

# Check if the release tag exists and list its assets
echo "=== Checking release tag ==="
gh api repos/oven-sh/WebKit/releases/tags/autobuild-preview-pr-228-3a85cc44 \
  --jq '{name: .name, tag: .tag_name, draft: .draft, prerelease: .prerelease, assets: [.assets[].name]}' \
  || echo "ERROR: Release tag not found"

# Check if PR `#228` exists
echo -e "\n=== Checking upstream PR `#228` ==="
gh api repos/oven-sh/WebKit/pulls/228 \
  --jq '{number: .number, title: .title, state: .state, merged: .merged}' \
  || echo "ERROR: PR not found"

Repository: oven-sh/bun

Length of output: 445


The release tag autobuild-preview-pr-228-3a85cc44 does not exist in oven-sh/WebKit.

The WEBKIT_VERSION points to a preview release that cannot be found (HTTP 404). When the build attempts to download prebuilt artifacts, it will fail. The upstream PR #228 exists and is open (not yet merged), which explains why the preview release doesn't exist.

Either create the release tag in oven-sh/WebKit with the required platform artifacts, or point WEBKIT_VERSION to an existing stable release and track the upstream PR separately.

🤖 Prompt for 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.

In `@scripts/build/deps/webkit.ts` at line 6, The WEBKIT_VERSION constant
currently points to a non-existent preview release ("preview-pr-228-3a85cc44")
causing 404s when downloading artifacts; fix by either creating the matching
release/tag and uploading the required platform artifacts in the oven-sh/WebKit
repo, or update the WEBKIT_VERSION value to a known existing release tag
(replace the string in the WEBKIT_VERSION export) that contains the prebuilt
artifacts you need; ensure the chosen tag is reachable and contains assets for
all target platforms so the build can download successfully.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Incorrect module import behaviour (probably JSC bug?) #7342 - Sibling modules importing from a shared TLA module get ReferenceError: Cannot access uninitialized variable due to incorrect module evaluation ordering
  2. Dynamic Import fails when multiple Vite plugins import same file #22367 - Concurrent import() calls to the same module cause ReferenceError: Cannot access 'default' before initialization (sibling dynamic import race)
  3. ReferenceError: Cannot access uninitialized variable. #11449 - Two dynamically imported files sharing a TLA dependency hit TDZ errors from premature evaluation

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #7342
Fixes #22367
Fixes #11449

🤖 Generated with Claude Code

* From https://github.com/oven-sh/WebKit releases.
*/
export const WEBKIT_VERSION = "5488984d20e0dbfe4be2c3ba8fb18eb81a5e0e8b";
export const WEBKIT_VERSION = "preview-pr-228-3a85cc44";

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.

🔴 WEBKIT_VERSION is set to the temporary preview tag preview-pr-228-3a85cc44 instead of a commit SHA. prebuiltUrl() (lines 76-78) only special-cases the autobuild- prefix, so the download URL becomes .../releases/download/autobuild-preview-pr-228-3a85cc44/... and prebuilt-mode builds will 404; it also corrupts process.versions.webkit and the cache key (prebuiltDestDir() slices to preview-pr-228-3). This needs to be replaced with the merged commit hash from oven-sh/WebKit#228 before landing.

Extended reasoning...

What the bug is

WEBKIT_VERSION at scripts/build/deps/webkit.ts:6 is changed from a 40-char commit SHA (5488984d20e0dbfe4be2c3ba8fb18eb81a5e0e8b) to "preview-pr-228-3a85cc44", a temporary preview-build tag for the still-open oven-sh/WebKit#228. The doc comment immediately above (lines 1-5) explicitly documents this constant as a "WebKit commit — determines prebuilt download URL + what to checkout for local mode", and git log -p on this file shows every prior value has been a full commit SHA. Nothing in scripts/ handles a preview-pr- prefix (grep finds no other occurrence).

Code path that triggers it

config.ts:56 wires WEBKIT_VERSION directly into cfg.webkitVersion with no normalization. prebuiltUrl() at webkit.ts:76-78 then computes the release tag as:

const tag = version.startsWith("autobuild-") ? version : `autobuild-${version}`;
return `https://github.com/oven-sh/WebKit/releases/download/${tag}/${name}.tar.gz`;

There is no branch for preview-pr-. Since the default cfg.webkit mode is "prebuilt" (used by CI and most local builds), this URL is the one fetched whenever the WebKit dep is resolved.

Why existing code doesn't prevent it

The only special case in prebuiltUrl() is the autobuild- prefix check, intended for users who pass --webkit-version=autobuild-<sha> verbatim. A preview-pr-... value falls into the else branch and gets autobuild- prepended. The override-detection warning at config.ts:1073-1076 ("you're not using the default WebKit version") only fires when the runtime value differs from the default — here the bad value is the default, so no warning fires.

Step-by-step proof

  1. cfg.webkitVersion = "preview-pr-228-3a85cc44" (default from WEBKIT_VERSION).
  2. prebuiltUrl(): "preview-pr-228-3a85cc44".startsWith("autobuild-")false.
  3. tag = "autobuild-preview-pr-228-3a85cc44".
  4. URL = https://github.com/oven-sh/WebKit/releases/download/autobuild-preview-pr-228-3a85cc44/bun-webkit-linux-amd64-lto.tar.gz.
  5. oven-sh/WebKit publishes preview builds under preview-pr-<n>-<sha> tags, not autobuild-preview-pr-..., so this asset does not exist → the WebKit fetch step 404s and the build fails.
  6. Even if such a release happened to exist, preview-PR tags are ephemeral and are GC'd once the WebKit PR merges, so any commit on main pinned to it would rot.

Secondary impact

  • depVersionsHeader.ts:59 emits cfg.webkitVersion verbatim into the generated header, so process.versions.webkit would report "preview-pr-228-3a85cc44" instead of a commit SHA.
  • prebuiltDestDir() at webkit.ts:86 keys the cache on .slice(0, 16) = "preview-pr-228-3", dropping the trailing commit-ish entirely; a future preview of the same PR would collide with this cache entry.
  • Local mode ("what to checkout") would attempt to check out a ref that isn't a commit in the WebKit repo.

Fix

Replace "preview-pr-228-3a85cc44" with the 40-char commit SHA of the merged oven-sh/WebKit#228 (i.e. wait for that PR to land, then pin to its merge commit) before merging this PR. This is the standard cross-repo workflow placeholder that needs to be swapped before landing.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up for maintainers: this is a WebKit-only fix (oven-sh/WebKit#228) with a `WEBKIT_VERSION` bump here. The internal gate/CI can only verify the fix end-to-end once the WebKit preview build at `autobuild-preview-pr-228-3a85cc44` publishes its tarball (currently in_progress — https://github.com/oven-sh/WebKit/actions/runs/25827484733). Once that lands, Bun CI should link the patched JSC and the new regression test passes.

Verified locally with a custom WebKit build: bun bd test test/js/bun/resolve/dynamic-import-tla-cycle.test.ts — 4 pass, 2 todo, 0 fail. Also verified the user's original Lexical repro prints ok. Follows the same WebKit#215 → #30262 coordination pattern.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red on every lane due to the WebKit preview tarball 404oven-sh/WebKit#228's build is still in_progress (~18min at last check) at https://github.com/oven-sh/WebKit/actions/runs/25827484733. Once it publishes autobuild-preview-pr-228-3a85cc44 artifacts, Bun CI can download JSC and the cpp builds will pass. Diff itself is green — just the test file + WEBKIT_VERSION bump.

Needs maintainer to either (a) let WebKit#228 land first and then re-point WEBKIT_VERSION at the merged SHA, or (b) merge WebKit#228 now and I can repoint here on a follow-up commit.

Comment on lines +20 to +21
test.todo("dynamic import inside TLA whose target imports the awaiter back does not deadlock");
test.todo("dynamic import inside TLA of a non-entry module whose target imports it back does not deadlock");

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.

🔴 Converting the two reduced self-deadlock tests to .todo is correct, but the real Nitro-output fixture they were reduced from was not updated: test/js/bun/http/bun-server.test.ts:405 ("should be able to parse source map and fetch small stream") spawns js-sink-sourmap-fixture/index.mjs, whose top-level await fetch(/stream) triggers import('./chunks/stream.mjs'), and chunks/stream.mjs:6 statically imports back ../index.mjs. With the 11.c.v skip dropped that self-deadlocks, Bun.serve keeps the loop alive so there's no unsettled-TLA exit, and the test calls Bun.spawnSync with no timeout — so the entire bun-server.test.ts file will hang in CI. That test needs the same .todo treatment (or at minimum a spawnSync timeout) before this lands.

Extended reasoning...

What the bug is

The WebKit change (oven-sh/WebKit#228) drops the Bun-specific deadlock-avoidance skip at innerModuleEvaluation step 11.c.v. The PR description acknowledges that the "Nitro-style await import(./chunk)-loops-back" pattern now matches spec/Node behaviour (deadlock), and accordingly converts the two reduced reproductions in dynamic-import-tla-cycle.test.ts to test.todo.

However, those reduced tests were minimised from a real Nitro output fixture that still exists in the test suite: test/js/bun/http/js-sink-sourmap-fixture/. The now-deleted reduction even uses the identical file naming (index.mjs + chunks/stream.mjs). The original fixture was not given the same .todo treatment, and with the skip removed it will hang.

Code path

  1. bun-server.test.ts:405-415 runs Bun.spawnSync({ cmd: [bunExe(), "js-sink-sourmap-fixture/index.mjs"], ... }) — synchronous, no timeout option.
  2. js-sink-sourmap-fixture/index.mjs:5577-5599 is module-top-level code: it calls Bun.serve(...), then const result = await fetch(${server.url}/stream). This top-level await makes index.mjs HasTLA; the module suspends here with status EvaluatingAsync.
  3. The /stream route is wired to _lazy_VB431L = () => import('./chunks/stream.mjs') (line 5442/5447) via h3's defineLazyEventHandler (line 2146-2171), which does Promise.resolve(factory()).then(...) on first request. So serving the request awaits the import() promise.
  4. chunks/stream.mjs:6 has import { e as eventHandler } from '../index.mjs'; — a static import back to the still-EvaluatingAsync entry.

Why existing code doesn't prevent it

With the custom skip removed, spec step 11.c applies when stream.mjs's fresh Evaluate() DFS visits index.mjs (status evaluating-async, AsyncEvaluation true): stream.mjs.[[PendingAsyncDependencies]] is incremented to 1 and stream.mjs is appended to index.mjs.[[AsyncParentModules]]. stream.mjs's body is therefore not executed; the import() promise stays pending until index.mjs finishes async evaluation. But index.mjs is suspended at await fetch(...), which can only resolve once the /stream handler responds, which requires stream.mjs to evaluate — circular wait.

Bun.serve's listening socket keeps the event loop alive, so the "unsettled top-level await" exit path does not fire. The child process hangs indefinitely. Even if idleTimeout eventually rejects the fetch (→ catchprocess.exit(1)), the test still goes from PASS → FAIL on expect(exitCode).toBe(0).

This wasn't caught locally because, per the robobun comment, only dynamic-import-tla-cycle.test.ts was run with the patched JSC; full Bun CI is still blocked on the WebKit preview tarball publishing.

Step-by-step proof

  1. Test runner thread enters Bun.spawnSync at bun-server.test.ts:406 and blocks in native code waiting for the child.
  2. Child evaluates index.mjs → starts Bun.serve → reaches await fetch('/stream') at line 5599 → module status becomes EvaluatingAsync, JS stack unwinds.
  3. Server receives the request → lazyEventHandler calls import('./chunks/stream.mjs').
  4. innerModuleEvaluation(stream.mjs) recurses into ../index.mjs → finds AsyncEvaluation == true → sets stream.[[PendingAsyncDependencies]] = 1, returns. Back in stream.mjs: pendingAsyncDependencies > 0 → body NOT executed. import() returns an unsettled promise.
  5. Lazy handler's .then(r => r.default ...) never fires → no Response → fetch at 5599 never settles → index.mjs never finishes → stream.mjs is never released. Server socket keeps the loop alive → no process exit.
  6. spawnSync blocks the parent's JS thread; bun:test's async per-test timer cannot interrupt a thread blocked in a native syscall. The whole bun-server.test.ts file stalls.

Impact

CI-breaking regression: bun-server.test.ts will hang once a build links the patched JSC. Best case (if something eventually times the connection out) the test goes PASS → FAIL.

Fix

Mark bun-server.test.ts:405 as test.todo alongside the two reduced cases (referencing the same follow-up tracking), or add a timeout to the Bun.spawnSync call and convert the assertion to expect the deadlock until the narrower referrer-aware skip lands. Longer term the fixture could be restructured so chunks/stream.mjs no longer statically re-imports index.mjs.

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Alternative approach in #30656 / oven-sh/WebKit#230: instead of dropping the re-entrancy skip (which regresses the Nitro self-deadlock tests), narrow it with a fourth discriminator — dep == dynamic-import-initiator. The initiator is threaded through ModuleLoadingContextModuleLoaderPayload and push/popped around the target's evaluate() via a HashCountedSet on VM. That way the Nitro case (target statically re-imports the initiator) still skips, and the parallel dynamic-import case (initiator != dep) falls through to the spec wait. Both sets of tests pass locally on debug-local.

robobun added 2 commits May 14, 2026 17:30
`@lexical/react` 0.44+ (and other packages that dispatch dev/prod via
`await import()` in a wrapper module) throws "Cannot access
'HISTORY_MERGE_TAG' before initialization" — a TDZ read of a wrapper's
post-`await` `export const` binding — when two siblings of the wrapper
are dynamic-imported concurrently from a `Promise.all` at the top.

Root cause: Bun-specific skip at `innerModuleEvaluation` step 11.c.v
that was added for the `require(esm)` / dynamic-import self-deadlock
case (TLA A awaits `import(B)`, B imports A) was indistinguishable from
the sibling-race case at its decision point, so it skipped the wait
there too and let consumers execute while the wrapper's bindings were
still in TDZ.

Fixed in oven-sh/WebKit#228 by dropping the custom skip entirely and
matching spec / Node behaviour for both shapes. See the WebKit PR for
full analysis of why no passive signal at 11.c.v distinguishes the two
shapes and why the trade-off favours this direction (the broken pattern
is a genuine self-deadlock users need to restructure anyway; the
previously-crashing pattern is widely used by Meta's Lexical editor
framework).

Bumps WebKit to `preview-pr-228-3a85cc44`.

## Tests

`test/js/bun/resolve/dynamic-import-tla-cycle.test.ts`:
- New test exercises the #30634 pattern (sibling dynamic imports via
  `Promise.all` sharing a TLA wrapper) — PASS.
- Existing tests for same-DFS sibling narrowing (tests 3-5, from #30259)
  — PASS.
- Existing deadlock-avoidance tests for the Nitro-style
  `await import('./chunk')`-loops-back pattern (tests 1-2) — marked
  `.todo`. These test a behaviour that will require proper
  referrer-aware skip (threading the dynamic-import referrer from
  `moduleLoaderImportModule` through `ModuleLoaderPayload` to the
  evaluate path) — tracked for follow-up.
@robobun robobun force-pushed the farm/cb50e83f/fix-tla-sibling-dynamic-import branch from 85a4794 to 4c57af9 Compare May 14, 2026 17:30
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Closing — #30656 supersedes this PR with a better approach (narrows the re-entrancy skip instead of dropping it, preserving the Nitro deadlock tests).

@robobun robobun closed this May 14, 2026
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.

1 participant