Skip to content

fix(shims): guard AsyncLocalStorage construction for browser bundles - #2020

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/als-registry-browser-safe
Jun 14, 2026
Merged

fix(shims): guard AsyncLocalStorage construction for browser bundles#2020
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/als-registry-browser-safe

Conversation

@Xplod13

@Xplod13 Xplod13 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Guard the AsyncLocalStorage construction in the shared ALS registry so client-reachable shims don't crash when bundled for the browser.
  • Falls back to a no-op store (getStore()undefined) when no usable AsyncLocalStorage constructor is available.

Root Cause

getOrCreateAls() in shims/internal/als-registry.ts called new AsyncLocalStorage() unconditionally. Many client-reachable shims (request-context, headers, cache, …) call getOrCreateAls() at module-evaluation time. In browser bundles, node:async_hooks resolves to a stub without a usable constructor, so new AsyncLocalStorage() throws TypeError: AsyncLocalStorage is not a constructor while the module is evaluating — killing the whole client bundle and breaking hydration.

The fix checks typeof AsyncLocalStorage === "function" before constructing and otherwise returns a NoopAsyncLocalStorage whose getStore() returns undefined, so the shims take their existing non-ALS fallback path. The cross-bundle singleton (Symbol.for + ??=) is preserved and no call sites change. This mirrors Next.js' createAsyncLocalStorage() / FakeAsyncLocalStorage and follows this repo's own async-hooks-stub.ts convention (no-op run executes the callback rather than throwing).

References

Verification

  • CI=true pnpm test tests/als-registry.test.ts — new test mocking node:async_hooks to an unusable constructor: red before (TypeError: AsyncLocalStorage is not a constructor), green after; asserts no throw, getStore() === undefined, and the same-key singleton still holds.
  • CI=true pnpm test — full battery green (only the pre-existing env flakes deploy.test.ts > resolveWranglerBin and oxlint-prefer-shared-utils, reproduced identically on origin/main).
  • CI=true npx vp check — clean on both changed files.

## Summary
- Guard `getOrCreateAls` so it no longer calls `new AsyncLocalStorage()`
  unconditionally; when the runtime does not expose a usable constructor it
  falls back to a no-op store instead of throwing.
- Add a `NoopAsyncLocalStorage` whose `getStore()` returns `undefined`, mirroring
  Next.js' `FakeAsyncLocalStorage` and this repo's `async-hooks-stub.ts`.
- Preserve the cross-bundle singleton behavior (`Symbol.for` + `??=`).

## Root Cause
In browser/client bundles `node:async_hooks` can resolve to a stub without a
usable `AsyncLocalStorage` constructor (e.g. Vite's `__vite-browser-external`).
`getOrCreateAls` ran `new AsyncLocalStorage()` at module-eval time, throwing
`TypeError: AsyncLocalStorage is not a constructor`. Because many client-reachable
shims (request-context, headers, cache, …) call this helper on import, the whole
client bundle died and hydration crashed. Checking
`typeof AsyncLocalStorage === "function"` before constructing, with a no-op
fallback, matches Next.js' availability guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2020
npm i https://pkg.pr.new/vinext@2020

commit: a242519

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk 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.

Reviewed the change. The fix is correct and well-scoped: it guards new AsyncLocalStorage() behind a typeof === "function" check and falls back to a no-op store, matching the suggested fix in #2015, Next.js' FakeAsyncLocalStorage, and the existing async-hooks-stub.ts convention in this repo. The cross-bundle singleton (Symbol.for + ??=) is preserved and no call sites change.

Verified locally:

  • vp test run tests/als-registry.test.ts — 2 passed
  • vp check on both changed files — clean (format, lint, types)

The NoopAsyncLocalStorage implements all methods the shims actually call (getStore, run), plus enterWith/exit/disable, so client-reachable shims that take the run() path won't crash either. LGTM with a couple of non-blocking notes below.

Note on defense-in-depth: there is already an asyncHooksStubPlugin (src/plugins/async-hooks-stub.ts) that aliases node:async_hooks to a working no-op AsyncLocalStorage in the client environment. This PR is a belt-and-suspenders guard for bundling paths where that stub doesn't apply (e.g. consumers bundling the published dist/ directly, as described in #2015). That is a reasonable second line of defense — just flagging that the two no-op implementations now need to stay in sync.

// The fallback mirrors Next.js' FakeAsyncLocalStorage: getStore() === undefined
// so shims fall back to their non-ALS code path instead of crashing.
expect(als).toBeDefined();
expect(als!.getStore()).toBeUndefined();

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.

Minor: this assertion doesn't strongly prove the no-op fallback path was taken — the real AsyncLocalStorage also returns undefined from getStore() when no run() scope is active. The meaningful guarantee in this test is the not.toThrow() above. Consider asserting on something noop-specific to lock in behavior, e.g. that run() still invokes the callback and getStore() inside it remains undefined (a real ALS would return the store there):

Suggested change
expect(als!.getStore()).toBeUndefined();
expect(als).toBeDefined();
expect(als!.getStore()).toBeUndefined();
// No-op specific: run() invokes the callback but getStore() stays undefined
// (a real AsyncLocalStorage would surface the store inside run()).
const seen = als!.run({ value: 1 }, () => als!.getStore());
expect(seen).toBeUndefined();

* methods are best-effort no-ops that still invoke the callback.
* See: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/async-local-storage.ts
*/
class NoopAsyncLocalStorage<T> {

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.

Non-blocking: this NoopAsyncLocalStorage duplicates the no-op AsyncLocalStorage already defined inline in src/plugins/async-hooks-stub.ts. They are semantically identical today, but they can drift. Not necessary for this PR, but worth a follow-up to share a single source of truth (or at least cross-reference each other in a comment) so the client-stub and the registry fallback stay consistent.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my findings on PR #2020:

Verdict: LGTM (with non-blocking notes)

The fix is correct and well-scoped. It guards new AsyncLocalStorage() behind a typeof === "function" check, falling back to a no-op store when no usable constructor exists.

Strengths:

Verified locally:

  • vp test run tests/als-registry.test.ts → 2 passed
  • vp check on both files → clean (format, lint, types)

Non-blocking notes posted as inline comments:

  1. Test weaknessexpect(als!.getStore()).toBeUndefined() doesn't strongly distinguish the noop from a real ALS (both return undefined outside a run() scope). Suggested adding a noop-specific assertion (call run() and check getStore() inside still returns undefined).
  2. Duplication — the new NoopAsyncLocalStorage duplicates the inline no-op in async-hooks-stub.ts; flagged as a potential drift risk for a future follow-up.

Neither note blocks merging.

github run

@james-elicx
james-elicx merged commit 201f4a6 into cloudflare:main Jun 14, 2026
46 checks passed
@Xplod13
Xplod13 deleted the fix/als-registry-browser-safe branch June 15, 2026 19:48
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.

Client-reachable Next shims crash in browser bundles via the AsyncLocalStorage registry

2 participants