Skip to content

Circular re-export between two use-server modules crashes at load #1208

Description

@vivek7405

Problem

Two 'use server' modules that re-export from each other in a cycle crash at module load, on Node and Bun alike:

ReferenceError: Cannot access 'ring' before initialization

Minimal repro (both files under the app, both 'use server', plain NAMED re-exports, no export *):

// c1.server.js
'use server';
export { helper } from './c2.server.js';
export async function ring() {}

// c2.server.js
'use server';
export { ring } from './c1.server.js';
export async function helper() {}

await import('./c1.server.js') throws. Without the seeding load hook installed the same pair loads fine, so this is the facade, not the app code.

Root cause. The generated facade rewrites every enumerated export as a const:

export const ring = __w("/abs/c2.server.js", "ring", __orig["ring"]);

export async function ring() is a HOISTED function declaration, so a cycle over it is legal JavaScript and works untransformed. Turning it into export const replaces hoisting with a temporal dead zone, so the partner module's facade body, which evaluates first in the cycle, reads __orig["ring"] while that binding is still uninitialised.

Not caused by #1155. Confirmed empirically: restoring the pre-#1155 hasStar bail-out reproduces the crash byte-identically, and the old /\bexport\s*\*/ pattern never matched this shape anyway, so the code path is unchanged by that PR. This dates to the facade itself (#472, Bun-enabled in #529).

Impact is low frequency but total when hit: the page does not render, the action is unreachable on both transports, and the error names an app symbol rather than anything pointing at the framework.

Design / approach

The constraint that makes this non-trivial: __actionWrap must return the value that is EXPORTED (it may be a recording Proxy when seeding is on), and it must run at load so identity is registered before any render resolves a bound <form action=${fn}> (#1155). So the naive fixes each cost something:

  • Emit a hoisted function wrapper (export function ring(...a) { return __wrapped(...a); }) restores hoisting, but the exported object is no longer the Proxy, so actionIdentityOf would need the wrapper registered too, and any framework or app code comparing function identity across the boundary sees a different object.
  • Lazy/getter-based export is not expressible for a const ESM binding.
  • Detect the cycle and pass the module through unfaceted is the cheapest and matches the existing fail-open posture (a passthrough already happens for a module with no enumerable exports). Since Unify form submissions on <form action=${action}>, drop the page action export #1155 it costs more than it used to: a passthrough module registers no identity, so <form action=${fn}> on one of its exports throws at SSR. That tradeoff needs stating either way.

A fourth option worth measuring first: emit let plus an assignment, or keep const but reference __orig lazily inside a hoisted accessor. Whether any of these actually resolves the cycle depends on evaluation order and should be tested rather than reasoned about.

No preference is baked in here. Whichever lands, the acceptance criteria below are what it has to satisfy.

Implementation notes (for the implementing agent)

Where to edit

  • packages/server/src/action-seed.js buildFacade() (around L322-346) is where the facade source is generated. The offending line is L341: out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`; The default-export line is L344 and has the same shape.
  • packages/server/src/action-seed.js buildSeedFacade() (around L356) is the decision point that returns null for a passthrough, if the chosen fix is detection-based.
  • packages/server/src/action-seed.js extractExportNames() (around L296) is what enumerates the names. It does NOT currently distinguish a function DECLARATION from a const arrow, and that distinction is exactly what the hoisting fix would need. reFn is the function-declaration regex.
  • packages/server/src/action-seed.js __actionWrap() (around L224) registers identity into _identity and builds the seed Proxy. Any fix that changes what gets exported must keep identity registered for the value the importer actually receives, or <form action=${fn}> breaks.

Landmines

  • The facade is RUNTIME-NEUTRAL and shared: packages/server/src/action-seed-bun.js installBunSeedPlugin() passes the same buildSeedFacade into a Bun.plugin onLoad (L39, L56). Any change must be proven on Bun as well as Node. Bun's onLoad must return an object for every filter match, so a passthrough there serves raw source rather than deferring.
  • Identity now rides this facade (Unify form submissions on <form action=${action}>, drop the page action export #1155). Before that PR a passthrough cost only a seed and was documented as harmless. It is now a hard render failure: an unfaceted module registers no identity, so resolveActionIdentity returns null and <form action=${action}> throws at SSR for a function that still works over RPC. See packages/server/src/form-action-identity.js. Do not reintroduce a silent passthrough without accounting for this.
  • The export * from '?webjs-seed-orig' catch-all at L338 is the dogfood: make the seed facade fail-open on an export-enumeration miss (export * catch-all) #535 fail-open guard. An explicit export const NAME shadows the matching star binding, which is what lets an enumerated export still be wrapped. Preserve that relationship.
  • Registration order matters: __actionWrap appends to a per-function list, defining module FIRST, and resolveActionIdentity takes the first entry the action index knows. A fix that changes when the facade body evaluates could invert that order. Covered by packages/server/test/routing/form-action-identity.test.js.
  • hashFile over the ABSOLUTE path is the identity basis and must not change.

Invariants to respect

  • packages/ is plain .js with JSDoc. No .ts files there (AGENTS.md, "Working in the WebJs framework repo itself").
  • No build step. The facade is generated source served through a load hook.
  • Fail-open remains the posture for seeding (a miss degrades to a normal RPC, never wrong data), but NOT for identity, per the Unify form submissions on <form action=${action}>, drop the page action export #1155 note above.
  • Prose punctuation rules (AGENTS.md invariant 11) apply to any comment or doc touched.

Tests + docs surfaces

  • Unit: packages/server/test/seed/action-seed-unit.test.js (pure extractExportNames / buildSeedFacade shape assertions) and packages/server/test/seed/seed-hook.test.js (the real load hook, isolated process because module.registerHooks is process-global). The cycle repro needs the real hook, so it belongs in the latter.
  • Bun parity: the seeding hook is runtime-sensitive, so add or extend a test/bun/*.mjs cross-runtime assertion and run node scripts/run-bun-tests.js. The crash reproduces on Bun today, so a fix that only works on Node is incomplete.
  • Docs: packages/server/AGENTS.md has the action-seed.js module-map row describing the faceting decision; update it if the decision changes. No user-facing docs-site page covers the facade (grepped website/app/docs, .agents/, packages/cli/templates), so there is likely nothing to sync there.

Acceptance criteria

  • The two-module cycle above loads without throwing, with the hook installed, on Node AND on Bun
  • Both exports are importable and callable through the cycle
  • Both still register action identity, so <form action=${ring}> renders and dispatches (or, if the chosen fix is a passthrough, the identity consequence is documented and a bound form on such a module fails with a message naming the real cause rather than "is not a server action")
  • Seeding still works for a non-cyclic module (no regression to the feat: seed SSR action results into hydration so async render does not re-fetch (follow-up to #469) #472 path)
  • The export * fail-open catch-all (dogfood: make the seed facade fail-open on an export-enumeration miss (export * catch-all) #535) still shadows correctly for enumerated exports
  • A counterfactual proves the new test fires: revert the fix, the named test reds
  • Bun parity test added or extended, and node scripts/run-bun-tests.js reported green
  • packages/server/AGENTS.md module-map row updated if the faceting decision changed

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions