Skip to content

feat(rsc): support custom server function directives#1246

Open
james-elicx wants to merge 13 commits into
vitejs:mainfrom
james-elicx:codex/server-function-directives
Open

feat(rsc): support custom server function directives#1246
james-elicx wants to merge 13 commits into
vitejs:mainfrom
james-elicx:codex/server-function-directives

Conversation

@james-elicx

@james-elicx james-elicx commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an experimental serverFunctionDirectives API for framework directives such as Next.js-style "use cache".

This is additive to the existing rsc:use-server implementation:

  • the existing vitePluginUseServer transform remains in place
  • custom directives run in a separate focused plugin immediately before it
  • the only behavioral seam in vitePluginUseServer is preserving custom manifest metadata during cleanup and merging custom export names when a module also has "use server"

API

serverFunctionDirectives: [{
  directive: /^use cache(?:: .+)?$/,
  test: (code) => code.includes("use cache"),
  filter: (id) => !id.includes("/node_modules/"),
  rejectNonAsyncFunction: true,
  validate({ directive, location }) {},
  filterExport({ name, meta }) {},
  wrap({ value, name, id, directiveMatch, location, hasBoundArgs }) {
    return `frameworkWrap(${value})`
  },
}]

Reused functionality

The custom-directive plugin delegates to the same transform helpers used by "use server" for:

  • inline scope/capture analysis and closure hoisting
  • bound-argument encryption
  • module export wrapping and proxy generation
  • normalized server-reference IDs and manifest entries
  • export-star expansion and export filtering

It also supports object methods, static class methods, destructured exports, named re-exports, stable custom hoist names, stateful regular expressions, and export filtering before async validation.

@james-elicx james-elicx force-pushed the codex/server-function-directives branch 2 times, most recently from 79377ac to c44f424 Compare June 11, 2026 14:10
@james-elicx james-elicx force-pushed the codex/server-function-directives branch 2 times, most recently from 205122e to 1aabe31 Compare June 11, 2026 14:46
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/plugins/server-function-directives.ts Outdated
Comment thread packages/plugin-rsc/src/transforms/hoist.test.ts Outdated
@james-elicx james-elicx force-pushed the codex/server-function-directives branch from 1aabe31 to 142fb07 Compare June 11, 2026 15:46
@james-elicx james-elicx marked this pull request as ready for review June 11, 2026 17:11
@hi-ogawa hi-ogawa added the trigger: preview Trigger pkg.pr.new label Jun 12, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vitejs/plugin-react@1246
npm i https://pkg.pr.new/@vitejs/plugin-rsc@1246
npm i https://pkg.pr.new/@vitejs/plugin-react-swc@1246

commit: 82d2c57

@hi-ogawa hi-ogawa 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.

Can you explain higher level motivation (than implementation explanation)? From a quick look, I cannot tell things like:

  • whether this includes some fixes in existing transforms wrap/hoist and thus existing builtin "use server" support.
  • whether new plugin vitePluginServerFunctionDirectives needs to be inside rsc plugin. for example, if we land changes in transforms/*, then can you build vitePluginServerFunctionDirectives outside based on the exported transform utils?
  • whether vitePluginServerFunctionDirectives has some new mechanism compared to current builtin "use server" support that also benefit for builtin use server to have.

Comment thread packages/plugin-rsc/src/transforms/server-action.ts
@james-elicx

james-elicx commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Can you explain higher level motivation (than implementation explanation)? From a quick look, I cannot tell things like:

  • whether this includes some fixes in existing transforms wrap/hoist and thus existing builtin "use server" support.

Yeah, there's some improvements to how object & class methods and named expressions are treated, which the 'use server' directive will benefit from. The other part is how certain forms that can't be hoisted safely are handled and tracking metadata about parameters, which is mainly for custom directives to use. I can break those out into separate PRs if needed.

  • whether new plugin vitePluginServerFunctionDirectives needs to be inside rsc plugin. for example, if we land changes in transforms/*, then can you build vitePluginServerFunctionDirectives outside based on the exported transform utils?

I believe we may be able to, however, the more I was looking at some of the 'use cache' directive stuff we were having to do as I started fixing bugs, the more it felt like we were touching more and more on the internals of how the plugin processes directives.

By that, I'm referring to:

  • server reference manifests.
  • the runtime proxies.
  • the encryption/encoding behaviour for closures.
  • some scope tracking.

It felt a lot to me like we began implementing our own generic approach to register custom directives rather than there being an API for one, and in the process, attaching onto everything the RSC plugin was already doing internally.

  • whether vitePluginServerFunctionDirectives has some new mechanism compared to current builtin "use server" support that also benefit for builtin use server to have.

The 'use server' directive could actually be changed to route through this new plugin as well. I was originally going to do that and have one single way for people to register custom directives that was also used for registering the 'use server' directive by default. The way this works was based on how that directive works as its starting point.

Couldn't help but feel a tad risky without proving the new approach works well before doing that...

Comment thread packages/plugin-rsc/src/transforms/server-action.ts
@hi-ogawa hi-ogawa self-assigned this Jun 12, 2026
@james-elicx james-elicx force-pushed the codex/server-function-directives branch from 5586d70 to e539c58 Compare June 12, 2026 01:10
Comment thread packages/plugin-rsc/src/transforms/server-action.ts
@james-elicx

Copy link
Copy Markdown
Contributor Author

Thanks for the prereleases :)

Exposed a couple of issues when integrating which codex has worked though.

Changes pushed to allow importing an additional runtime for the wrap callback to use, and adding the (opt-in) ability to allow cached RSC values containing server references to be replayed/re-rendered by frameworks by preserving them, and also fixing some issues with references across environments.

You can see what the framework side of this might look like in https://github.com/cloudflare/vinext/pull/1871/changes

Sorry about that.

@ivogt

ivogt commented Jun 13, 2026

Copy link
Copy Markdown

+1 to @hi-ogawa's question about whether vitePluginServerFunctionDirectives needs to live inside the RSC plugin, vs. landing the transforms/* changes and letting frameworks build the directive plugin externally on the exported utils. I think it should stay external.

We maintain a long-developed RSC framework (router + RSC runtime on @vitejs/plugin-rsc) that we're preparing to release publicly, and keeping an eye on PRs in case something breaks for us. It has shipped "use cache" for a while — built exactly the way @hi-ogawa describes: an external, rsc-env-only plugin composed on transformWrapExport / transformHoistInlineDirective. Notably, vinext did the same before this PR. So there are already (at least) two frameworks composing directive handling externally from those utils.

In our model, "use cache" is purely server-local memoization: the function runs during server render, its args form a cache key, its result is serialized into Flight. It's never exposed to the client, never a server reference, no client/ssr proxy. The new API (if I'm not mistaken) encodes the opposite contract: in the rsc env wrap()'s output is unconditionally nested in registerServerReference(...), and the client/ssr envs unconditionally emit createServerReference(..., callServer, ...) proxies + a manifest entry. That's possibly right for the vinext/Next semantics ("cache fns passed as props / invoked as actions") , but the hooks (wrap/filterExport/validate) sit above that wiring, so a server-local model can't be expressed through it. A single in-core plugin basically makes server-reference the only model.

Agreeing with @hi-ogawa's direction to land the transforms/* improvements and keep those utils a first-class, stable public surface, so frameworks can compose their own directive handling. And if vitePluginServerFunctionDirectives does ship in-core, consider letting a directive opt out of reference registration + proxy generation (a "server-local" mode where wrap owns the whole output), so the server-reference model isn't the only possible shape.

These two would let frameworks that don't model "use cache" as a server reference keep working the way they do today. None of this blocks the vinext use case — happy to share more if needed.

@james-elicx

james-elicx commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Hi @ivogt, thanks for the feedback :)

Notably, vinext did the same before this PR. So there are already (at least) two frameworks composing directive handling externally from those utils.

for what it's worth, our initial implementation for 'use cache' is full of bugs, missing functionality, and poorly done - i wouldn't use it as an example 😅. This PR came from when I was looking at fixing some of our issues.

In our model, "use cache" is purely server-local memoization: the function runs during server render, its args form a cache key, its result is serialized into Flight. It's never exposed to the client, never a server reference, no client/ssr proxy. The new API (if I'm not mistaken) encodes the opposite contract: in the rsc env wrap()'s output is unconditionally nested in registerServerReference(...), and the client/ssr envs unconditionally emit createServerReference(..., callServer, ...) proxies + a manifest entry. That's possibly right for the vinext/Next semantics ("cache fns passed as props / invoked as actions") , but the hooks (wrap/filterExport/validate) sit above that wiring, so a server-local model can't be expressed through it. A single in-core plugin basically makes server-reference the only model.

And if vitePluginServerFunctionDirectives does ship in-core, consider letting a directive opt out of reference registration + proxy generation (a "server-local" mode where wrap owns the whole output), so the server-reference model isn't the only possible shape.

This API is intended to be for directives that should be server functions that can also be called from client components, hence the naming - similiar to how the 'use server' directive would create functions that you can call from other environments.

https://react.dev/reference/rsc/server-functions

It sounds like you're not using server functions in your use case, so this feature would not necessarily be something that you would use, unless your functions are intended to be callable from the client. From the sounds of it, your use case is similar to something like React's cache(), where you're memoizing a function within server components.

Please let me know if I'm mistaken in my understanding of what you're doing though.

@ivogt

ivogt commented Jun 13, 2026

Copy link
Copy Markdown

Thanks for the detailed reply.

One precision so we're on the same page: we do use server functions heavily, for actions, via the built-in "use server". What we don't have is a custom client-callable directive, which is what serverFunctionDirectives is for if I understand correctly. Our only custom directive is "use cache", and it's server-local (never callable from the client), so it's out of scope for this API. So "not our use case" is about serverFunctionDirectives specifically, not server functions in general — those we rely on constantly.

On the cache() comparison: it's that shape (memoize in server components, function never crosses the wire), but a bit more than React's cache() — ours is a durable cross-request store with TTL, stale-while-revalidate, tags, and a pluggable backend (memory / CF KV / Cache API), not per-render dedupe. Same "not a server function" conclusion though.

So nothing for serverFunctionDirectives to do for us, agreed. The one thing I'd still gently land, echoing @hi-ogawa's question, is keeping the low-level transforms/* utils (transformWrapExport / transformHoistInlineDirective) a stable public surface. That's what lets a server-local model like ours be composed externally, without needing this in-core. Thanks again 🙏

@james-elicx

Copy link
Copy Markdown
Contributor Author

So nothing for serverFunctionDirectives to do for us, agreed. The one thing I'd still gently land, echoing hi-ogawa's question, is keeping the low-level transforms/* utils (transformWrapExport / transformHoistInlineDirective) a stable public surface. That's what lets a server-local model like ours be composed externally, without needing this in-core. Thanks again 🙏

Sorry, I’m not sure I fully understand the last part. I’m not proposing to remove the existing exports - i agree they should stay available as removing them would be a breaking change.

When you say "without needing this in-core", do you mean that "server-local" directives like yours shouldn't need to use the new API? Or are you suggesting that the RSC plugin shouldn’t include an API for custom directives that create server references?

I would probs agree that server-local ones shouldn't need this sort of thing, but im less clear on the argument for the latter, as this API would be bringing together handling of manifests, proxies, arg binding, encryption, normalization, serialization, etc., into one shared approach.

@ivogt

ivogt commented Jun 13, 2026

Copy link
Copy Markdown

I think I might have over-worried earlier — I thought that landing this pr it would change how server actions "use server" (e.g. getNormalizedId and serverReferenceMetaMap shape) get handled for us. It seems like it doesn't. Also that "use cache" (by adding the registerServerReference(...)) would effectively get baked into the plugin so we couldn't keep our own transform. But the transforms/* utils stay exported and the new API is opt-in, so our external, server-local transform should keep working.

Appreciate you walking through it.

One genuinely useful flag while you're in the shared transforms: transformWrapExport now returns meta.isFunction as boolean | undefined instead of defaulting to false. Consumers branching on meta.isFunction === false (we do, in our wrap filter) shift behavior — a call-expression-initialized export comes back undefined now, not false. Might be intended; we'll adapt it anyway.

@james-elicx

Copy link
Copy Markdown
Contributor Author

One genuinely useful flag while you're in the shared transforms: transformWrapExport now returns meta.isFunction as boolean | undefined instead of defaulting to false. Consumers branching on meta.isFunction === false (we do, in our wrap filter) shift behavior — a call-expression-initialized export comes back undefined now, not false. Might be intended; we'll adapt it anyway.

The types for isFunction are already boolean | undefined in main, which means your consumption of the function would be making an incorrect assumption if it thinks that could never be possible, as the public API explicitly states that it could be undefined.

  • false -> we know it's definitely not a function.
  • true -> we know it is a function.
  • undefined -> we don't know if it's a function.

https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/src/transforms/wrap-export.ts#L8

In fact, I think this could potentially be an improvement by honouring the public types. There are cases where the plugin may not be able to statically analyse the value of a variable as a function. Take factory functions that return callbacks as an example - there's a decent chance they might not be statically analysed as resulting the value of the variable being a function. I haven't tested whether they are or not, but the public types would allow you to have better control over that scenario if it's accurately returning undefined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trigger: preview Trigger pkg.pr.new

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants