Motivation
transformServerActionServer currently selects between two distinct transform paths:
|
// TODO: unify (generalize transformHoistInlineDirective to support top-level directive cases) |
|
if (hasDirective(ast.body, 'use server')) { |
|
const result = transformWrapExport(input, ast, options) |
|
return { ...result, referenceNames: result.exportNames } |
|
} |
|
const result = transformHoistInlineDirective(input, ast, { |
|
...options, |
|
directive: 'use server', |
|
}) |
|
return { ...result, referenceNames: result.names } |
The code contains an existing TODO to generalize transformHoistInlineDirective for top-level directive cases.
The current split is understandable from the implementation history, but it creates cognitive load because the two paths use different output models:
module-level
exported implementation
-> reassign export to runtime(implementation)
inline
private or generated implementation
-> runtime(implementation)
-> generated reference export
-> optional lexical capture binding
Understanding or changing Server Function lowering currently requires keeping both models in mind. The module path also has declaration-specific machinery such as converting exported const bindings to let so they can be reassigned, while the inline path naturally creates a separate canonical binding.
The goal is to investigate whether internal module-export consumers can adopt the inline path's clearer callable model and share its canonical-binding logic. The inline transform is a comparison point and potential shared-primitive consumer, not a behavioral target of the refactor.
Three lowering shapes
Assume a simple exported function with no closure captures:
export async function action(value) {
"use server"
return value
}
transformWrapExport
The current module-level transform:
- Removes the original export syntax while keeping the local declaration.
- Reassigns the local binding to
runtime(binding).
- Exports the reassigned binding under the original name.
async function action(value) {
return value
}
action = runtime(action, 'action')
export { action }
An exported const must first become let so this reassignment is legal.
Hoist without hoistRuntime
The inline transform:
- Moves the original function body into a generated module-level function.
- Replaces the original declaration with a binding initialized to
runtime(generatedFunction).
export async function $$hoist_action(value) {
return value
}
export const action = runtime($$hoist_action, '$$hoist_action')
The generated function is the reported reference export. The original source name holds the runtime result.
This is behaviorally fine when runtime(...) annotates and returns the same function, as built-in "use server" does. If runtime(...) creates a distinct callable, the reported reference still resolves to the raw generated implementation.
Hoist with hoistRuntime
PR #1330 adds this form.
The runtime-hoisted transform:
- Moves the original body into a generated private implementation.
- Creates a second generated binding initialized to
runtime(implementation).
- Replaces the original declaration with the second generated binding.
async function $$hoist_action$$impl(value) {
return value
}
export const $$hoist_action = runtime(
$$hoist_action$$impl,
'$$hoist_action',
)
export const action = $$hoist_action
The generated runtime binding is both the reported reference export and the value used by the original source name.
Possible shared model
These forms can be described through four stages:
1. establish implementation binding
2. produce canonical callable with runtime(implementation)
3. select canonical reference export/name
4. replace or export the original source binding
transformWrapExport currently combines these stages through reassignment. The hoist path makes the implementation and canonical callable explicit. The investigation asks whether internal module export loops can use the same explicit model without adopting the inline path's closure analysis and lexical binding machinery.
Direction
Keep the public transformWrapExport helper and its established behavior for backward compatibility, but deprecate it in favor of the new module-export transform. External integrations are not required to migrate immediately.
Introduce a new preferred module-export transform and a smaller internal callable-lowering primitive shared with transformHoistInlineDirective. Migrate plugin-rsc's internal consumers and maintained examples to the new transform so the deprecated helper remains only as a compatibility surface.
source-specific extraction
-> implementation binding
-> callable production mode
-> canonical binding/reference
-> source-specific export or lexical replacement
The module lowerer should retain an export loop. It should not use the inline transform's full AST walk, closure analysis, bind/encode/decode behavior, or generated inline naming. The shared logic should be limited to producing and reporting canonical callables.
This is an investigation into the right shared shape and replacement API. It is not a proposal to remove the old helper or add Server Function behavior.
Callable production modes
Identity runtime
Built-in "use server" registration annotates and returns the same function. The implementation, canonical callable, and reference export can remain one binding:
export async function action(value) {
return value
}
runtime(action, 'action')
The runtime call is a side effect rather than a replacement value.
Wrapper runtime
Runtimes such as the RSC CSS wrapper or a cache wrapper return a distinct callable. Use an explicit private implementation and canonical export:
async function action$$impl(value) {
return value
}
export const action = runtime(action$$impl, 'action')
Modulo generated naming, this is already mostly the inline hoist shape with noExport: true and hoistRuntime: false:
async function $$hoist_0_action(value) {
return value
}
export const action = runtime($$hoist_0_action, '$$hoist_0_action')
The module lowerer needs source export names rather than private generated names, but it does not need inline capture machinery.
Inline wrapper
The inline wrapper keeps its current behavior:
async function $$hoist_action$$impl(captured, value) {
return captured + value
}
export const $$hoist_action = runtime(
$$hoist_action$$impl,
'$$hoist_action',
)
const action = $$hoist_action.bind(null, captured)
The new internal primitive may generate the implementation-to-canonical-binding portion, while transformHoistInlineDirective continues to own extraction, captures, and lexical replacement.
Candidate internal API
Treat identity registration and wrapper production as explicit modes in the shared primitive:
type CallableProduction =
| { kind: 'effect'; runtime: RuntimeCallback }
| { kind: 'wrapper'; runtime: RuntimeCallback }
Effect mode keeps the implementation as the canonical callable and emits the runtime call once at module scope:
export async function action() {}
runtime(action, 'action')
Built-in module-level "use server" can migrate to effect mode. An identity-producing inline runtime could potentially reuse the same mode while retaining the hoister's generated export and lexical replacement.
Wrapper mode uses the runtime result as the canonical callable:
async function Component$$impl() {}
export const Component = runtime(Component$$impl, 'Component')
RSC CSS export wrapping and other distinct-wrapper consumers remain in wrapper mode. Inline distinct wrappers use the same mode with a generated canonical export and source-site alias or bind.
If explored through the existing hoist options, prefer one mode option over another independent boolean:
runtimeMode?: 'source' | 'hoist' | 'effect'
This is an API candidate, not a proposed public contract. A private tagged representation may be clearer. Effect calls must not be annotated /* #__PURE__ */, must execute exactly once, and need deliberate module placement and source mapping.
Migration targets
Built-in module-level "use server"
transformServerActionServer currently calls transformWrapExport for a module-level directive in packages/plugin-rsc/src/transforms/server-action.ts.
Migrate this path to the new internal module lowerer in identity mode. Preserve source exports, emit registration as a side effect, and continue reporting source export names through referenceNames.
RSC CSS export wrapping
transformRscCssExport currently calls transformWrapExport in packages/plugin-rsc/src/plugin.ts.
Migrate it to wrapper mode while preserving:
- Export filtering.
- Component-name metadata.
- Non-function passthrough.
- Unchanged export-all declarations.
- CSS-loading wrapper behavior.
This target verifies that the shared model supports a real distinct-wrapper consumer unrelated to Server Function directives.
Inline directive hoisting
Refactor transformHoistInlineDirective to use the shared callable-lowering primitive where it reduces duplication. Generated output and public behavior should remain unchanged.
Maintained example consumers
Migrate the custom-server-function and use-cache-callable examples to the new preferred module-export transform:
custom-server-function exercises identity-producing registration.
use-cache-callable exercises a distinct wrapper runtime.
Together they verify that the replacement API is sufficient for external framework integrations. transformWrapExport remains available only so existing external consumers are not broken.
Next.js reference
As in issue #1335, maintained Next.js server-actions fixtures should guide the preferred lowering shape when multiple implementations are possible.
Identity-runtime module action
Server graph fixture 3 contains:
// app/send.ts
'use server'
export async function myAction(a, b, c) {
console.log('a')
}
Its checked output is:
// app/send.ts
/* __next_internal_action_entry_do_not_use__ {"70e10665baac148856374b2789aceb970f66fec33e":{"name":"myAction"}} */ import { registerServerReference } from "private-next-rsc-server-reference";
export async function myAction(a, b, c) {
console.log('a');
}
import { ensureServerEntryExports } from "private-next-rsc-action-validate";
ensureServerEntryExports([
myAction
]);
registerServerReference(myAction, "70e10665baac148856374b2789aceb970f66fec33e", null);
This is the identity-runtime mode: the source export remains the implementation, canonical callable, and reference export, while registration is emitted by side effect.
ensureServerEntryExports is separate runtime validation for a module-level "use server" boundary. It checks that every exported value is a function and throws the "use server"-file diagnostic otherwise. It does not produce or register the callable.
Wrapper-runtime module export
Server graph fixture 35 lowers a module-level cached export into:
private implementation
-> generated canonical wrapper export
-> source export alias
Server graph fixture 33 uses the same wrapper decomposition for an inline cached function. The source placement differs, but callable production is shared.
Generated names, IDs, imports, validation, and framework runtime calls do not need to match Next.js. Compare implementation placement, canonical callable selection, registration placement, and source export aliasing.
Preserving plugin-rsc's current reassignment and live-binding output is not itself the target. Analyze function hoisting, mutable exports, cycles, aliases, defaults, filtering, and source maps so any semantic change is understood, but prefer the simpler Next.js-aligned decomposition unless a concrete JavaScript or Vite constraint justifies divergence.
Investigation
Prototype:
- A small internal representation or emitter for identity and wrapper callable production.
- A module export loop that consumes it without using inline capture machinery.
- Internal migrations for built-in module actions and RSC CSS wrappers.
- Reuse from
transformHoistInlineDirective without changing its output.
Determine:
- Whether identity and wrapper modes share enough logic to justify the abstraction.
- Whether effect mode is best represented by a tagged internal callable-production type or a
runtimeMode option.
- Where one-time effect calls should be emitted and mapped, and how to prevent accidental pure annotations or repeated execution.
- Whether module exports and inline hoists can report canonical names through one internal result shape.
- Whether the new lowerer handles named, default, aliased, variable, filtered, and re-exported bindings cleanly.
- Whether the output preserves required JavaScript module semantics and source-map placement.
- Whether migrating both internal consumers and maintained examples leaves
transformWrapExport used only by compatibility tests and unknown external consumers.
- Whether the resulting code is easier to understand than keeping the current specialized paths.
Verification
Add focused module-level fixtures for:
- Named and default function exports in identity mode.
- Variable exports in identity mode.
- Distinct wrappers for named, default, and variable exports.
- Aliases and re-exports.
- Filtering and unchanged export-all declarations.
- Function hoisting, mutable bindings, and relevant module cycles.
- Returned canonical reference names.
- Runtime call source maps.
- Effect calls execute exactly once and are retained by tree shaking.
Retain existing inline transform snapshots as unchanged reference cases. Add or update internal tests for:
- Built-in module-level
"use server" behavior.
- RSC CSS wrapper naming, filtering, non-function passthrough, and CSS loading.
- Module-level identity registration in the
custom-server-function example.
- Module-level distinct wrapping in the
use-cache-callable example.
- Existing built-in Server Function end-to-end behavior.
Compare representative outputs manually with the linked Next.js fixtures.
Expected outcome
Produce a concrete recommendation:
- Adopt the shared internal callable-lowering primitive, expose the preferred replacement transform, migrate internal consumers and maintained examples, and deprecate
transformWrapExport.
- Narrow the shared primitive if only canonical binding emission is genuinely common.
- Or retain separate implementations if the abstraction adds more complexity than it removes.
An implementation may follow if the prototype demonstrates a clear maintenance and cognitive-load improvement.
Non-goals
- Removing or breaking the deprecated public
transformWrapExport API.
- Requiring unknown external consumers to migrate immediately.
- Changing inline directive behavior, syntax support, captures, binding, or generated identity.
- Mixed
"use server" and "use cache" ownership or composition.
- Directive precedence or multi-owner role classification.
- Client or SSR proxy redesign.
- Cache policy, argument metadata, or transport changes.
Research context
"use cache"Server Functions #1336Motivation
transformServerActionServercurrently selects between two distinct transform paths:vite-plugin-react/packages/plugin-rsc/src/transforms/server-action.ts
Lines 30 to 39 in b8c9264
The code contains an existing TODO to generalize
transformHoistInlineDirectivefor top-level directive cases.The current split is understandable from the implementation history, but it creates cognitive load because the two paths use different output models:
Understanding or changing Server Function lowering currently requires keeping both models in mind. The module path also has declaration-specific machinery such as converting exported
constbindings toletso they can be reassigned, while the inline path naturally creates a separate canonical binding.The goal is to investigate whether internal module-export consumers can adopt the inline path's clearer callable model and share its canonical-binding logic. The inline transform is a comparison point and potential shared-primitive consumer, not a behavioral target of the refactor.
Three lowering shapes
Assume a simple exported function with no closure captures:
transformWrapExportThe current module-level transform:
runtime(binding).An exported
constmust first becomeletso this reassignment is legal.Hoist without
hoistRuntimeThe inline transform:
runtime(generatedFunction).The generated function is the reported reference export. The original source name holds the runtime result.
This is behaviorally fine when
runtime(...)annotates and returns the same function, as built-in"use server"does. Ifruntime(...)creates a distinct callable, the reported reference still resolves to the raw generated implementation.Hoist with
hoistRuntimePR #1330 adds this form.
The runtime-hoisted transform:
runtime(implementation).The generated runtime binding is both the reported reference export and the value used by the original source name.
Possible shared model
These forms can be described through four stages:
transformWrapExportcurrently combines these stages through reassignment. The hoist path makes the implementation and canonical callable explicit. The investigation asks whether internal module export loops can use the same explicit model without adopting the inline path's closure analysis and lexical binding machinery.Direction
Keep the public
transformWrapExporthelper and its established behavior for backward compatibility, but deprecate it in favor of the new module-export transform. External integrations are not required to migrate immediately.Introduce a new preferred module-export transform and a smaller internal callable-lowering primitive shared with
transformHoistInlineDirective. Migrate plugin-rsc's internal consumers and maintained examples to the new transform so the deprecated helper remains only as a compatibility surface.The module lowerer should retain an export loop. It should not use the inline transform's full AST walk, closure analysis, bind/encode/decode behavior, or generated inline naming. The shared logic should be limited to producing and reporting canonical callables.
This is an investigation into the right shared shape and replacement API. It is not a proposal to remove the old helper or add Server Function behavior.
Callable production modes
Identity runtime
Built-in
"use server"registration annotates and returns the same function. The implementation, canonical callable, and reference export can remain one binding:The runtime call is a side effect rather than a replacement value.
Wrapper runtime
Runtimes such as the RSC CSS wrapper or a cache wrapper return a distinct callable. Use an explicit private implementation and canonical export:
Modulo generated naming, this is already mostly the inline hoist shape with
noExport: trueandhoistRuntime: false:The module lowerer needs source export names rather than private generated names, but it does not need inline capture machinery.
Inline wrapper
The inline wrapper keeps its current behavior:
The new internal primitive may generate the implementation-to-canonical-binding portion, while
transformHoistInlineDirectivecontinues to own extraction, captures, and lexical replacement.Candidate internal API
Treat identity registration and wrapper production as explicit modes in the shared primitive:
Effect mode keeps the implementation as the canonical callable and emits the runtime call once at module scope:
Built-in module-level
"use server"can migrate to effect mode. An identity-producing inline runtime could potentially reuse the same mode while retaining the hoister's generated export and lexical replacement.Wrapper mode uses the runtime result as the canonical callable:
RSC CSS export wrapping and other distinct-wrapper consumers remain in wrapper mode. Inline distinct wrappers use the same mode with a generated canonical export and source-site alias or bind.
If explored through the existing hoist options, prefer one mode option over another independent boolean:
This is an API candidate, not a proposed public contract. A private tagged representation may be clearer. Effect calls must not be annotated
/* #__PURE__ */, must execute exactly once, and need deliberate module placement and source mapping.Migration targets
Built-in module-level
"use server"transformServerActionServercurrently callstransformWrapExportfor a module-level directive inpackages/plugin-rsc/src/transforms/server-action.ts.Migrate this path to the new internal module lowerer in identity mode. Preserve source exports, emit registration as a side effect, and continue reporting source export names through
referenceNames.RSC CSS export wrapping
transformRscCssExportcurrently callstransformWrapExportinpackages/plugin-rsc/src/plugin.ts.Migrate it to wrapper mode while preserving:
This target verifies that the shared model supports a real distinct-wrapper consumer unrelated to Server Function directives.
Inline directive hoisting
Refactor
transformHoistInlineDirectiveto use the shared callable-lowering primitive where it reduces duplication. Generated output and public behavior should remain unchanged.Maintained example consumers
Migrate the
custom-server-functionanduse-cache-callableexamples to the new preferred module-export transform:custom-server-functionexercises identity-producing registration.use-cache-callableexercises a distinct wrapper runtime.Together they verify that the replacement API is sufficient for external framework integrations.
transformWrapExportremains available only so existing external consumers are not broken.Next.js reference
As in issue #1335, maintained Next.js server-actions fixtures should guide the preferred lowering shape when multiple implementations are possible.
Identity-runtime module action
Server graph fixture 3 contains:
Its checked output is:
This is the identity-runtime mode: the source export remains the implementation, canonical callable, and reference export, while registration is emitted by side effect.
ensureServerEntryExportsis separate runtime validation for a module-level"use server"boundary. It checks that every exported value is a function and throws the"use server"-file diagnostic otherwise. It does not produce or register the callable.Wrapper-runtime module export
Server graph fixture 35 lowers a module-level cached export into:
Server graph fixture 33 uses the same wrapper decomposition for an inline cached function. The source placement differs, but callable production is shared.
Generated names, IDs, imports, validation, and framework runtime calls do not need to match Next.js. Compare implementation placement, canonical callable selection, registration placement, and source export aliasing.
Preserving plugin-rsc's current reassignment and live-binding output is not itself the target. Analyze function hoisting, mutable exports, cycles, aliases, defaults, filtering, and source maps so any semantic change is understood, but prefer the simpler Next.js-aligned decomposition unless a concrete JavaScript or Vite constraint justifies divergence.
Investigation
Prototype:
transformHoistInlineDirectivewithout changing its output.Determine:
runtimeModeoption.transformWrapExportused only by compatibility tests and unknown external consumers.Verification
Add focused module-level fixtures for:
Retain existing inline transform snapshots as unchanged reference cases. Add or update internal tests for:
"use server"behavior.custom-server-functionexample.use-cache-callableexample.Compare representative outputs manually with the linked Next.js fixtures.
Expected outcome
Produce a concrete recommendation:
transformWrapExport.An implementation may follow if the prototype demonstrates a clear maintenance and cognitive-load improvement.
Non-goals
transformWrapExportAPI."use server"and"use cache"ownership or composition.Research context