You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-#1155hasStar 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.jsbuildFacade() (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.jsbuildSeedFacade() (around L356) is the decision point that returns null for a passthrough, if the chosen fix is detection-based.
packages/server/src/action-seed.jsextractExportNames() (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.jsinstallBunSeedPlugin() passes the same buildSeedFacade into a Bun.pluginonLoad (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.
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.
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")
Problem
Two
'use server'modules that re-export from each other in a cycle crash at module load, on Node and Bun alike:Minimal repro (both files under the app, both
'use server', plain NAMED re-exports, noexport *):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 async function ring()is a HOISTED function declaration, so a cycle over it is legal JavaScript and works untransformed. Turning it intoexport constreplaces 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
hasStarbail-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:
__actionWrapmust 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:export function ring(...a) { return __wrapped(...a); }) restores hoisting, but the exported object is no longer the Proxy, soactionIdentityOfwould need the wrapper registered too, and any framework or app code comparing function identity across the boundary sees a different object.constESM binding.<form action=${fn}>on one of its exports throws at SSR. That tradeoff needs stating either way.A fourth option worth measuring first: emit
letplus an assignment, or keepconstbut reference__origlazily 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.jsbuildFacade()(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.jsbuildSeedFacade()(around L356) is the decision point that returnsnullfor a passthrough, if the chosen fix is detection-based.packages/server/src/action-seed.jsextractExportNames()(around L296) is what enumerates the names. It does NOT currently distinguish a function DECLARATION from aconstarrow, and that distinction is exactly what the hoisting fix would need.reFnis the function-declaration regex.packages/server/src/action-seed.js__actionWrap()(around L224) registers identity into_identityand 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
packages/server/src/action-seed-bun.jsinstallBunSeedPlugin()passes the samebuildSeedFacadeinto aBun.pluginonLoad(L39, L56). Any change must be proven on Bun as well as Node. Bun'sonLoadmust return an object for every filter match, so a passthrough there serves raw source rather than deferring.resolveActionIdentityreturns null and<form action=${action}>throws at SSR for a function that still works over RPC. Seepackages/server/src/form-action-identity.js. Do not reintroduce a silent passthrough without accounting for this.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 explicitexport const NAMEshadows the matching star binding, which is what lets an enumerated export still be wrapped. Preserve that relationship.__actionWrapappends to a per-function list, defining module FIRST, andresolveActionIdentitytakes the first entry the action index knows. A fix that changes when the facade body evaluates could invert that order. Covered bypackages/server/test/routing/form-action-identity.test.js.hashFileover the ABSOLUTE path is the identity basis and must not change.Invariants to respect
packages/is plain.jswith JSDoc. No.tsfiles there (AGENTS.md, "Working in the WebJs framework repo itself").Tests + docs surfaces
packages/server/test/seed/action-seed-unit.test.js(pureextractExportNames/buildSeedFacadeshape assertions) andpackages/server/test/seed/seed-hook.test.js(the real load hook, isolated process becausemodule.registerHooksis process-global). The cycle repro needs the real hook, so it belongs in the latter.test/bun/*.mjscross-runtime assertion and runnode scripts/run-bun-tests.js. The crash reproduces on Bun today, so a fix that only works on Node is incomplete.packages/server/AGENTS.mdhas theaction-seed.jsmodule-map row describing the faceting decision; update it if the decision changes. No user-facing docs-site page covers the facade (greppedwebsite/app/docs,.agents/,packages/cli/templates), so there is likely nothing to sync there.Acceptance criteria
<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")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 exportsnode scripts/run-bun-tests.jsreported greenpackages/server/AGENTS.mdmodule-map row updated if the faceting decision changed