Skip to content

Rework transformWrapExport and transformHoistInlineDirective #1350

Description

@hi-ogawa

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:

  1. Removes the original export syntax while keeping the local declaration.
  2. Reassigns the local binding to runtime(binding).
  3. 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:

  1. Moves the original function body into a generated module-level function.
  2. 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:

  1. Moves the original body into a generated private implementation.
  2. Creates a second generated binding initialized to runtime(implementation).
  3. 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:

  1. A small internal representation or emitter for identity and wrapper callable production.
  2. A module export loop that consumes it without using inline capture machinery.
  3. Internal migrations for built-in module actions and RSC CSS wrappers.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions