diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS-STABLE-CACHE-IDENTITY.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS-STABLE-CACHE-IDENTITY.md new file mode 100644 index 000000000..de0ea0e0e --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS-STABLE-CACHE-IDENTITY.md @@ -0,0 +1,282 @@ +# Stable Cache Identity + +- Repo: git@github.com:vitejs/vite-plugin-react.git +- Commit: 31cdbb82219b6b637eee338a3492f735c78116bf +- Branch: main +- Next.js repo: git@github.com:vercel/next.js.git +- Next.js commit: 153bf8ac5fa00888ef5fbb2b65cac12f0942a44f +- Next.js branch: canary +- Plugin-rsc PR #1246 head: 5a2fd7519fa6cce09af04b8b5288ecb8d4a2ddc3 +- Reviewed: 2026-07-24 + +## Question + +What identity information does the Next.js transform provide for `"use cache"`, what behavior depends on that transform-produced information, and does plugin-rsc PR #1246 move the generic Vite RSC transform in the same direction? + +The primary scope is the transform-to-runtime ABI. Runtime cache policy is relevant only when it demonstrates how transform-produced identity is consumed or why some information is intentionally absent from the transform. + +This note expands the stable-identity item under [Completed Follow-Ups](./FINDINGS.md#completed-follow-ups) and the initial generated-identity discussion under [FINDINGS.md:213](./FINDINGS.md#generated-identity). Cross-environment registration and protected bound arguments remain a separate pending follow-up. + +## Executive Findings + +1. Next.js emits one per-function server-reference ID and passes the same ID to both the cache runtime and React server-reference registration. +2. The ID is transform-produced from a build-provided salt, source filename, export or generated name, and parameter-shape metadata. Function bodies, closure values, and dependency code are not ID inputs. +3. The generated ID gives the cache runtime per-definition separation without requiring it to inspect the function object. It also gives the RSC transport a manifest-resolvable reference for the wrapped cached callable. +4. Exported functions use stable source export names. Inline functions use traversal-order generated names, so inserting an earlier transformed function can change later IDs even when those functions are untouched. +5. Next.js keeps captured closure values outside the function ID. The transform packages them as protected bound arguments, tells the cache runtime their count, and the runtime includes the decoded values with invocation arguments. +6. PR #1246 supplies the corresponding generic pieces: a generated name passed to the runtime, `hasBoundArgs`, parameter shape, and an exported wrapped hoist. Its `stableName` improves unrelated-insertion stability, but it is not equivalent to Next.js because it hashes the exact function source. +7. No additional transform metadata is needed merely because handlers are distributed when they consume the same emitted build. Persistence and deployment invalidation concern runtime key composition, not the directive transform. +8. A dependency-aware implementation revision would be new build-produced information for cross-deployment reuse. It is not part of current Next.js transform equivalence. + +## Transform-produced Identity In Next.js + +### ID generation + +The shared server-actions transform generates one server-reference ID for actions and cached functions in [crates/next-custom-transforms/src/transforms/server_actions.rs:284](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L284): + +```text +digest = SHA1(hashSalt + fileName + ":" + exportName) +metadata = cacheTypeBit | sixArgumentMask | restBit +serverReferenceId = hex(metadata + digest) +``` + +The transform receives `hashSalt` from the build configuration. The salt influences the emitted value, but its generation and rotation are build policy rather than syntax analysis. + +The leading metadata byte identifies a cache function and encodes positional argument admission in [server_actions.rs:308](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L308). The current implementation marks every declared parameter as used instead of analyzing references in the body in [server_actions.rs:339](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L339). + +The resulting ID therefore carries: + +- Cache function versus server action. +- Up to six admitted positional arguments. +- Rest or unknown argument admission. +- A salted source location and generated/export name identity. + +It does not carry: + +- Function body content. +- Imported dependency content. +- Closure capture values. +- Cache kind. +- Build, deployment, or HMR identity. + +### Exported and inline names + +For module exports, the hash input uses the source export name. For inline functions, the transform generates names such as `$$RSC_SERVER_ACTION_0` and `$$RSC_SERVER_CACHE_0` from one traversal-order counter in [server_actions.rs:381](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L381). + +This creates two identity behaviors: + +- An exported function retains its ID when unrelated code changes, provided filename, export name, parameter shape, and salt remain unchanged. +- An inline function can receive a new ID when an earlier action or cached function is inserted or removed because its generated ordinal changes. + +Thus, Next.js does not currently provide insertion-stable identity for every inline cached function. Its emitted ID is stable enough to address the function within one generated server-reference manifest, but it is not a semantic identity independent of transform traversal. + +### ID consumption in generated output + +For each cached function, the transform emits a wrapper with the normalized shape: + +```js +const wrapped = cache( + cacheKind, + serverReferenceId, + boundArgsLength, + innerFn, + admittedArgs, +) +registerServerReference(wrapped, serverReferenceId, null) +``` + +The cache call is assembled in [crates/next-custom-transforms/src/transforms/server_actions.rs:2979](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L2979), and the wrapped callable is registered as a server reference in [server_actions.rs:3127](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L3127). + +The same transform-produced ID therefore has two consumers: + +1. The cache runtime uses it as per-definition key material. +2. React server-function transport uses it to resolve the wrapped callable. + +This reuse is a Next.js output design, not proof that a generic Vite RSC implementation must encode cache policy into its server-reference registry. A generic transform only needs to make a deterministic function identity available to both consumers. + +## Captures Are Arguments, Not Function Identity + +The transform separately analyzes closure captures, inserts them into the hoisted implementation, and reports their count to the cache runtime. The runtime call receives `boundArgsLength`, while the generated server reference is bound to one encrypted capture payload. + +At runtime, the protected payload is decrypted and reconstructed before key serialization in [packages/next/src/server/use-cache/use-cache-wrapper.ts:1980](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1980). The capture values then participate in the argument portion of the cache key. + +This preserves the important distinction: + +- The transform-generated function ID identifies the cached definition. +- Bound capture values distinguish instances of that definition. +- Invocation arguments distinguish calls to that bound instance. + +The identity investigation therefore reinforces the earlier transform ABI findings. A generic hoister should expose whether a protected capture payload exists and how source parameters are admitted, but it should not hash capture values into the generated function name. + +## Transform Identity Stability Matrix + +This matrix concerns the emitted Next.js server-reference/function ID only. Runtime cache epochs are intentionally excluded. + +| Change | Exported cached function ID | Inline cached function ID | Transform reason | +| ------------------------------------ | --------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Identical transform inputs | Stable | Stable | All hash inputs and generated traversal names are unchanged. | +| Unrelated statement insertion | Stable | Stable unless it adds a transformed reference before the function | Source offsets are not hashed. | +| Earlier action/cache insertion | Stable | May change | Inline generated names use a shared traversal counter. | +| Function body edit | Stable | Stable if generated ordinal and source name remain unchanged | Body content is not hashed. | +| Whitespace/comment edit | Stable | Stable if traversal remains unchanged | Exact source slices are not hashed. | +| Parameter count/rest change | Changes | Changes | The metadata byte changes. | +| Parameter rename with the same shape | Stable | Stable | Parameter names are not hash inputs. | +| Export or source function rename | Changes | Stable if generated ordinal remains unchanged | Exported IDs use the export name; inline IDs use the generated traversal name rather than the source function name. | +| File rename or move | Changes | Changes | Filename is a hash input. | +| Imported dependency edit | Stable | Stable | Dependency content is not a transform ID input. | +| Different hash salt | Changes | Changes | Salt is an explicit transform input. | + +## Transform Information Inventory + +| Information | Next.js output | Why the cache integration needs it | PR #1246 | +| --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| Per-definition identity | `serverReferenceId` | Separates entries for different cached functions and addresses transport | Runtime receives generated hoist name; surrounding plugin can combine module identity | +| Cache/action classification | Leading ID type bit | Runtime and transport metadata classification | Directive match identifies custom cache semantics outside the generic transform | +| Parameter admission | Leading argument mask/rest bit plus generated slicing | Excludes framework-supplied extra arguments from cache keys | `parameters: { count, hasRest }` | +| Capture boundary | `boundArgsLength` and protected first argument | Reconstructs captures before keying and invocation | `hasBoundArgs` plus existing `encode`/`decode` hooks | +| Cache kind | Separate runtime argument | Chooses cache handler | `directiveMatch` | +| Wrapped export target | Registered/exported cache wrapper | Remote resolution must not bypass caching | `exportWrappedHoist` | +| Implementation content | Not present in current ID | Not needed for current transform equivalence | `stableName` includes only the local source slice | +| Dependency content | Not present in current ID | Only needed for finer-grained durable reuse | Not provided | + +## Current Vite RSC Identity + +### Module and export identity + +Plugin-rsc resolves a production module reference key as the first 12 hex characters of SHA-256 over the root-relative normalized import path in [packages/plugin-rsc/src/plugins/server-reference.ts:29](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/plugins/server-reference.ts#L29) and [packages/plugin-rsc/src/plugins/utils.ts:130](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/plugins/utils.ts#L130). Development uses a normalized Vite import URL. + +The complete server reference is effectively: + +```text +referenceKey + "#" + exportName +``` + +The server transform registers this pair in [packages/plugin-rsc/src/plugin.ts:2042](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/plugin.ts#L2042), client and SSR proxies recreate it in [plugin.ts:2097](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/plugin.ts#L2097), and runtime loading splits it back into module and export name in [packages/plugin-rsc/src/core/rsc.ts:94](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/core/rsc.ts#L94). + +PR #1310 changes ownership and cleanup rather than identity generation. Claims must agree on module identity and cannot assign the same export to different owners in [packages/plugin-rsc/src/plugins/server-reference.ts:69](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/src/plugins/server-reference.ts#L69). + +### Current inline identity + +The built-in hoister currently names inline references by traversal order. This has the same broad instability as Next.js inline generated names: inserting an earlier directive can rename later references. + +The server-local cache example does not consume generated names as cache identity. It memoizes by the hoisted JavaScript function object and then by serialized arguments in [packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx:14](https://github.com/vitejs/vite-plugin-react/blob/31cdbb82219b6b637eee338a3492f735c78116bf/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx#L14). That is sufficient for one process lifetime but does not expose per-definition identity to an external or shared handler. + +## PR #1246 `stableName` + +PR #1246 computes an opt-in inline name from: + +```text +signature = originalName + ":" + exactFunctionSourceSlice +generatedName = "$$hoist_" + sha256(signature)[0:12] + duplicateIndex + originalName +``` + +The implementation is in [packages/plugin-rsc/src/transforms/hoist.ts:156](https://github.com/vitejs/vite-plugin-react/blob/5a2fd7519fa6cce09af04b8b5288ecb8d4a2ddc3/packages/plugin-rsc/src/transforms/hoist.ts#L156). + +Compared with Next.js inline identity: + +| Change | Next.js inline ID | PR #1246 generated name | +| --------------------------------------- | -------------------------------- | ---------------------------------------------------------------------- | +| Unrelated earlier statement | Stable | Stable | +| Earlier non-identical cached function | Changes through ordinal | Stable | +| Body edit | Stable if ordinal/name unchanged | Changes | +| Whitespace/comment edit inside function | Stable | Changes | +| Imported dependency edit | Stable | Stable | +| File move | Changes through filename | Generated name remains; full Vite reference changes through module key | +| Exact duplicate insertion/reordering | Can change through ordinal | Can change through duplicate index | + +`stableName` is therefore not a direct reproduction of Next.js identity. It trades Next.js's body-stable logical identity for local source-content invalidation and improved resistance to unrelated insertion. + +When combined with `exportWrappedHoist`, the generated name addresses the exported cache wrapper instead of the raw hoisted implementation in [hoist.ts:167](https://github.com/vitejs/vite-plugin-react/blob/5a2fd7519fa6cce09af04b8b5288ecb8d4a2ddc3/packages/plugin-rsc/src/transforms/hoist.ts#L167). This output shape is aligned with Next.js because both cache and server-reference consumers must reach the same wrapped callable. + +## Assessment Of PR #1246 + +PR #1246 is in the direction established by the original transform comparison: + +- It exposes parameter shape rather than requiring the runtime to infer it from `Function.length`. +- It exposes the capture boundary before ordinary invocation arguments. +- It keeps cache kind in directive metadata. +- It can export the wrapped hoist as the manifest-addressable callable. +- It offers a generated inline identity that is less sensitive to unrelated transformed functions. + +The open design question is narrower than "stable cache identity" generally: + +> What stability contract should a generated inline reference name provide? + +Current Next.js does not answer this perfectly because its inline ordinals are insertion-sensitive. PR #1246 improves that case, but exact-source hashing also makes transport identity change for behaviorally irrelevant edits and does not cover dependency changes. The option should therefore be justified as an insertion-stable generated reference name, not as a complete persistent cache identity or a strict Next.js reproduction. + +For current Next.js-equivalent transform behavior, the surrounding integration still needs to combine: + +```text +canonical module identity + generated/export name +``` + +and pass that identity to both the cache wrapper and server-reference registration. PR #1246 provides the generated/export-name side; plugin-rsc's server-reference manager provides canonical module identity. + +## Runtime And Build Context + +This section records non-transform mechanisms only to delimit what the transform ID is responsible for. + +### Deployment and HMR epochs + +Next.js's cache runtime serializes normalized key parts of: + +```text +[ + deploymentId || buildId, + serverReferenceId, + decodedCaptureAndInvocationArguments, + optionalDevelopmentHmrRefreshHash, +] +``` + +The runtime explicitly says the action ID is not unique per implementation, so a build ID prevents unsafe reuse across builds in [packages/next/src/server/use-cache/use-cache-wrapper.ts:1843](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1843). It assembles the final key parts in [use-cache-wrapper.ts:2016](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L2016). + +`deploymentId` is effectively a runtime cache namespace constant. It does not add function-level transform information. Its relevance here is only that Next.js deliberately handles body and deployment invalidation outside the transform-generated function ID. + +Likewise, the development HMR refresh hash is not evidence that the transform needs source-content identity. It is an external invalidation mechanism compensating for IDs that remain stable across body edits. + +### Persistence and distributed handlers + +Next.js intentionally does not reuse ordinary `"use cache"` or `"use cache: remote"` entries across deployments. The documentation identifies build or deployment identity as the safety boundary in [docs/01-app/03-api-reference/01-directives/use-cache-remote.mdx:92](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/docs/01-app/03-api-reference/01-directives/use-cache-remote.mdx#L92). + +This policy does not introduce another transform requirement. Multiple handlers running the same emitted build naturally receive the same function IDs. Whether they share entries depends on the cache handler and key namespace, not additional directive metadata. + +### Experimental implementation hash + +Turbopack can emit a separate `codeHash` for cache references under `experimental.durableUseCacheEntries`. Manifest generation computes a server dependency-subtree hash in [crates/next-api/src/server_actions.rs:232](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-api/src/server_actions.rs#L232) and [server_actions.rs:360](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-api/src/server_actions.rs#L360). + +Tests show that it changes with the cached module and server dependencies while remaining stable for unrelated and client-only changes in [test/production/app-dir/use-cache-code-hash/use-cache-code-hash.test.ts:52](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/production/app-dir/use-cache-code-hash/use-cache-code-hash.test.ts#L52). + +The standard cache runtime does not currently consume this manifest field. It should be treated as possible future build-produced information for durable reuse, not as part of the current directive transform contract. + +## Focused Conclusions + +The stable-identity follow-up produces one additional transform conclusion beyond the original findings: + +> A cache integration that can outlive a JavaScript function object needs a deterministic per-definition identity supplied by generated output. + +For module exports, canonical module identity plus export name already provides that value. For inline functions, a generated exported name is required. PR #1246 supplies such a name and makes the wrapped callable exportable. + +The remaining choices are policy and stability details: + +- Whether inline identity should follow Next.js's traversal ordinal exactly. +- Whether it should instead survive unrelated transformed-function insertion. +- Whether body edits should change transport identity or be handled by an external build/HMR epoch. +- Whether a stronger dependency-aware implementation revision is ever needed for cross-deployment reuse. + +Only the first three affect the transform name contract. Deployment constants, shared storage, and invalidation epochs belong to the consuming cache integration. + +## Next Proof + +The next proof should remain transform-focused. Apply the Next.js and PR #1246 transforms to equivalent fixtures and record only generated identity and wrapper placement for: + +1. Two identical transforms. +2. An unrelated statement insertion. +3. An earlier inline cached-function insertion. +4. A body-only edit. +5. A whitespace-only edit. +6. A parameter-shape change. +7. A function rename. +8. A file move after composing module and generated identity. + +The deliverable should decide and test the intended `stableName` contract. Runtime cache-hit tests across deployments are not needed for that decision because they exercise namespace and storage policy rather than transform-provided information. diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS.md new file mode 100644 index 000000000..9d04c7d1d --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/FINDINGS.md @@ -0,0 +1,270 @@ +# `use cache` Transform Comparison Findings + +- Repo: git@github.com:vitejs/vite-plugin-react.git +- Commit: 32611a28365435d81a513c559715411bdc8f127e +- Branch: main +- Next.js repo: git@github.com:vercel/next.js.git +- Next.js commit: 153bf8ac5fa00888ef5fbb2b65cac12f0942a44f +- Next.js branch: canary + +## Executive Findings + +The minimal demo and Next.js use different transform-to-runtime ABIs, but the difference is larger than the demonstrated semantic gap. + +The demo lowers a cached function to a hoisted implementation wrapped by `cacheWrapper(fn)`, then uses ordinary `.bind()` to attach closure captures. Next.js emits a module-level wrapper that calls `cache(kind, id, boundArgsLength, innerFn, args)` and packages closure captures into one protected leading argument. + +For the current in-process RSC demo, the generic hoist-and-bind representation is sufficient for the transform-dependent central behavior: per-function cache separation, closure values in cache keys, nested closures, and cached component props. + +Most additional Next.js ABI fields support concerns deferred from this comparison, especially cross-environment server references, protected bound arguments, persistent identity, custom handlers, and framework policy. Their presence does not by itself demonstrate a limitation in the generic hoister. + +The clearest transform-relevant semantic difference in the first-pass scope is argument admission. Next.js emits whether to pass an empty list for an empty transformed signature, a fixed transformed-parameter prefix, or all arguments for a rest/unknown signature. The fixed prefix includes one protected capture-payload slot when captures exist. The demo wrapper receives and keys every supplied argument. Exact Next.js-style omission of extra arguments cannot be recovered reliably from the function object alone, so it requires different generated output or additional runtime metadata. + +Two apparent gaps are integration choices rather than generic-hoister limitations: + +- Single-argument capture packaging and reconstruction for the inner function are available through the hoister's existing `encode` and `decode` hooks, but the cache demo does not configure them. Making that boundary visible to the cache runtime still requires a self-describing payload or transform metadata. +- Cache-kind metadata is available through regex directive matching and `directiveMatch`, but the cache demo matches only exact `"use cache"` and ignores the metadata. + +## Emitted Pipelines + +### Minimal Demo + +The cache plugin calls the generic hoister with a one-argument runtime expression in [packages/plugin-rsc/examples/basic/vite.config.ts:338](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/vite.config.ts#L338). + +```text +source function + -> hoisted implementation H(captures..., invocationArgs...) + -> cacheWrapper(H) + -> optional .bind(null, captures...) + -> cached callable +``` + +Normalized output: + +```js +function outer(capture) { + const fn = cacheWrapper(H).bind(null, capture) + return fn +} + +async function H(capture, arg) { + 'use cache' + return body(capture, arg) +} +``` + +The transform prepends captures to the original parameters and applies binding after the runtime expression in [packages/plugin-rsc/src/transforms/hoist.ts:76](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.ts#L76) and [packages/plugin-rsc/src/transforms/hoist.ts:113](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.ts#L113). + +The runtime receives only `H`. A later invocation presents bound captures and call-time arguments as one positional `args` list in [packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx:20](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx#L20). + +### Next.js + +Next.js creates a distinct inner implementation and module-level runtime wrapper in [crates/next-custom-transforms/src/transforms/server_actions.rs:3063](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L3063). + +```text +source function + -> inner implementation H([captures...], invocationArgs...) + -> module-level React.cache wrapper + -> cache(kind, id, captureCount, H, admittedArgs) + -> registered callable + -> optional .bind(null, protectedCapturePayload) +``` + +Normalized output: + +```js +const H = async function fn([capture], arg) { + return body(capture, arg) +} + +export const REF = React.cache(function fn() { + return cache('default', ID, 1, H, slice.call(arguments, 0, 2)) +}) + +registerServerReference(REF, ID, null) + +function outer(capture) { + return REF.bind(null, encryptActionBoundArgs(ID, capture)) +} +``` + +The transform creates the capture-array parameter and records its length in [crates/next-custom-transforms/src/transforms/server_actions.rs:889](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L889). It emits the five-part runtime call in [crates/next-custom-transforms/src/transforms/server_actions.rs:2979](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L2979). + +A representative nested cache output is [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/40/output.js:6](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/40/output.js#L6). + +## Focused Fixture Comparison + +| Fixture | Minimal demo representation | Next.js representation | First-pass semantic result | +| ---------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Plain async function | `cacheWrapper(H)` | `React.cache(() => cache(kind, id, 0, H, []))` | Both provide one reusable callable during the loaded module lifetime. | +| Explicit arguments | Wrapper receives every supplied argument | Transform emits `[]`, a declared-prefix slice, or all arguments | Extra undeclared arguments affect demo keys but are omitted by Next.js for fixed signatures. | +| One closure capture | Capture is a leading parameter and direct `.bind()` argument | Captures form one leading array parameter and one protected bound payload | Both preserve the capture in execution and key material. Only Next.js preserves the boundary explicitly at runtime. | +| Nested closure | Runtime expression runs at the original nested site; runtime deduplicates by `H` | Base wrapper is hoisted once; nested site creates a bound callable | Current demo semantics are equivalent because its runtime memoizes wrappers by `H`. | +| Member-only capture | Transform constructs a partial object preserving source member paths | Transform binds selected leaf/path values and rewrites references | Both avoid capturing the unused root object. No feature difference was demonstrated. | +| Cached component props | Props are one ordinary invocation argument | Props are one ordinary invocation argument | No component-specific transform difference. Dynamic-child behavior is runtime-only here. | + +Next.js plain-function output is illustrated by [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/33/output.js:5](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/33/output.js#L5). Fixed-argument slicing is visible in [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/48/output.js:27](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/48/output.js#L27). Component props remain one argument in [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/41/output.js:13](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/41/output.js#L13). + +## Transform Information Inventory + +| Information | Minimal cache demo | Generic hoister capability | Next.js | +| ----------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Inner implementation | Passed directly as `fn` | Explicit | Passed explicitly as `originalFn` | +| Identity within loaded module | Hoisted function object | Generated hoist name is also available to runtime callback | Generated reference ID and wrapper object | +| Cache kind | Not emitted | `directiveMatch` can expose it when using a regex | Explicit `kind` argument | +| Capture values | Flattened into leading runtime arguments | Can be packaged with `encode` and reconstructed with `decode` | One protected payload reconstructed as a capture array | +| Capture count | Not passed to cache runtime | Known internally, but absent from runtime callback metadata | Explicit `boundArgsLength` | +| Capture/invocation boundary | Not observable to current runtime | Captures can occupy one encoded leading slot, but the runtime is not told whether that slot exists | Explicit through bound payload, count, and capture-array parameter | +| Positional argument admission | Not emitted; all supplied arguments reach runtime | AST and capture analysis have the information, but runtime callback metadata does not | Explicit generated `[]`/transformed-prefix-slice/all-arguments expression | +| Member-only capture | Partial object synthesized at bind site | Built in for plain member chains | Selected paths bound and body references rewritten | +| Registration metadata | Ignored by demo | Runtime callback receives generated hoist name; surrounding Vite plugin can add module identity | Generated ID, exported reference, registration call, and manifest comment | + +The generic runtime callback receives the generated value, name, and directive match in [packages/plugin-rsc/src/transforms/hoist.ts:21](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.ts#L21), but the demo callback uses only the value in [packages/plugin-rsc/examples/basic/vite.config.ts:346](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/vite.config.ts#L346). + +## Detailed Findings + +### 1. Capture Flattening Is A Demo Choice, Not A Hoister Ceiling + +The current cache demo does not configure `encode` or `decode`, so each captured variable becomes a direct leading `.bind()` argument and the cache runtime sees captures plus invocation arguments as one list. + +The generic transform can instead bind one encoded capture payload and decode it into the original capture bindings inside the hoisted function. This behavior is implemented in [packages/plugin-rsc/src/transforms/hoist.ts:81](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.ts#L81) and demonstrated by [packages/plugin-rsc/src/transforms/fixtures/hoist/member-chain.js.snap.encode.js:1](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/fixtures/hoist/member-chain.js.snap.encode.js#L1). + +The production server-action integration already uses these hooks for protected bound arguments in [packages/plugin-rsc/src/plugin.ts:2059](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/plugin.ts#L2059). + +This means the generic approach can package captures into one slot and reconstruct them inside the inner implementation. It does not by itself expose the slot's meaning to the cache runtime because `decode` runs inside `H`, after the wrapper has received the arguments. A cache runtime that must interpret the payload before calling `H` needs either a self-describing payload or capture metadata in the transform/runtime contract. + +### 2. Wrapper Placement Shifts A Requirement To The Runtime + +For a nested cached function, the demo emits `cacheWrapper(H)` at the original declaration site. Re-entering the outer function therefore evaluates the runtime expression again and creates a fresh `.bind()` result. + +The demo runtime compensates by memoizing `cacheWrapper(H)` in a `WeakMap`, so every evaluation for the same module-level `H` recovers the same base cached wrapper in [packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx:14](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx#L14). The bound callable is fresh, but cache entries remain attached to the shared base wrapper. + +Next.js hoists the base wrapper itself to module scope, then creates only the bound callable at the nested site. This makes one-time base-wrapper creation a transform guarantee rather than a runtime convention. + +No current semantic gap results because the demo runtime satisfies the convention. A future runtime that stores state during wrapper construction must retain function-object memoization or the transform would need to hoist wrapper creation as Next.js does. + +### 3. Argument Admission Is A Genuine Transform-Output Difference + +The demo wrapper is variadic and serializes every supplied argument. The transform preserves the original implementation parameters but does not tell the runtime how many invocation arguments are semantically admitted. + +Next.js emits argument normalization from the AST in [crates/next-custom-transforms/src/transforms/server_actions.rs:3005](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L3005): + +- `[]` when the transformed inner function has no parameters, which means it has neither captures nor source parameters. +- `slice(arguments, 0, transformedParameterCount)` for a fixed signature. This is the source parameter count plus one payload slot when the function has captures. +- `slice(arguments)` when rest parameters are present or the signature is not statically known. + +Consequently, `cached(1, 2, extra)` and `cached(1, 2, anotherExtra)` share a Next.js entry when the function declares only two fixed parameters, but produce different demo keys. + +`Function.length` cannot reproduce this exactly because default and rest parameters make it an incomplete representation of the source signature, and the runtime also needs to account for transform-generated capture slots. Exact parity requires a generated wrapper that performs the slicing before entering the runtime or transform-produced total-admitted-prefix metadata consumed by the runtime. + +### 4. Cache Kind Is Missing Only From The Demo Integration + +Next.js passes the parsed cache kind directly to its runtime, which uses it for handler selection in [packages/next/src/server/use-cache/use-cache-wrapper.ts:1612](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1612). Custom-kind output is shown in [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/38/output.js:4](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/38/output.js#L4). + +The demo matches only exact `"use cache"`, but the generic hoister supports regex directives and passes the match to the runtime callback. This is covered by [packages/plugin-rsc/src/transforms/hoist.test.ts:445](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.test.ts#L445). + +Supporting kind dispatch would require changing the demo integration and runtime signature, not the generic transform approach. + +### 5. Function Identity Is Adequate For The Current Demo Scope + +The demo scopes cache entries first by the hoisted function object and then by serialized arguments. That provides per-definition isolation while the module instance remains loaded. + +Next.js supplies a generated reference ID to the runtime and includes it in cache-key parts in [packages/next/src/server/use-cache/use-cache-wrapper.ts:2016](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L2016). The transform hashes salt, filename, and export/generated name, then adds a leading metadata byte encoding cache/action type and parameter/rest information in [crates/next-custom-transforms/src/transforms/server_actions.rs:284](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L284). + +This richer identity is not needed for the current in-memory demo because its entries cannot outlive the function object. Whether a generated ID is sufficient or necessary across builds, HMR, deployments, persistent stores, or distributed handlers remains a follow-up rather than a first-pass finding. + +### 6. Capture Representation Differs Without A Demonstrated Feature Gap + +For member-only access such as `x.y.z`, the generic hoister keeps the inner body unchanged and binds a synthesized partial object such as `{ y: { z: x.y.z } }`. The output is demonstrated in [packages/plugin-rsc/src/transforms/fixtures/hoist/member-chain.js.snap.js:1](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/fixtures/hoist/member-chain.js.snap.js#L1). + +Next.js collects member paths, retains the shortest covering path, binds selected values, and rewrites inner references. The path reduction is implemented in [crates/next-custom-transforms/src/transforms/server_actions.rs:2918](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L2918). + +Both approaches avoid serializing unused parts of the root object. The demo may serialize constant property labels and wrapper objects that Next.js avoids, but no relevant runtime feature was found that depends on this representational difference. Syntax coverage for computed access, optional access, and other complex forms belongs to the broader-transform follow-up. + +## Capability Matrix + +| Capability | Demo status | Transform dependency | Classification | +| ------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Per-definition in-memory cache isolation | Supported through hoisted function-object identity | Hoisted implementation object | Directly supported | +| Closure values participate in cache identity | Supported as leading arguments | Capture collection and binding | Directly supported | +| Nested cached closures share base cache state | Supported because runtime memoizes by hoisted function | Hoisted implementation identity; wrapper placement creates a runtime obligation | Runtime-only convention with current transform support | +| Distinguish one bound payload from call arguments | Not used by cache demo | Existing hooks can package captures, but runtime-visible distinction needs a tag or metadata | Integration protocol or small ABI extension | +| Ignore extra positional arguments | Not supported exactly | Total admitted transformed prefix and rest/unknown policy | Genuine generated-output change or ABI extension | +| Dispatch by cache kind | Not supported by demo | Directive match already available | Integration/runtime change, not a hoister extension | +| Invoke an unwrapped inner implementation | Supported because runtime receives `H` | Hoisted implementation | Directly supported | +| Persistent or distributed identity | Not evaluated | Likely requires generated identity and build/storage policy | Follow-up | +| Cross-environment bound references | Not evaluated | Requires registration and protected bound-reference protocol | Follow-up | + +## Smallest Relevant Changes + +### Exact Argument Admission + +Emit a generated wrapper that supplies only admitted arguments, or extend the transform/runtime contract with positional-argument policy. The minimum useful metadata for the latter is total admitted positional prefix, including any transform-generated capture slot, versus pass-all for rest or unknown signatures. This is the only clear generic generated-output change identified in the first pass. + +### Cache Kind + +Change the demo directive to a regex, derive the kind from `directiveMatch`, and call a runtime accepting `(kind, fn)`. No generic-hoister change is required. + +### Packaged Captures + +Use existing `encode` and `decode` hooks if a future inner implementation needs captures packaged into one bound payload. Use a self-describing payload or add capture metadata to the runtime callback if the cache runtime must inspect, decode, key, or validate that payload before invoking the inner function. + +### Generated Identity + +The runtime callback already receives the generated hoist name, and the surrounding Vite transform hook can incorporate module identity as the production server-action integration does in [packages/plugin-rsc/src/plugin.ts:2061](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/plugin.ts#L2061). Define the required lifetime and invalidation semantics before changing the cache ABI; that design belongs to the stable-identity follow-up. + +## Completed Follow-Ups + +- [Stability across builds, HMR, deployments, persistent stores, and distributed handlers](./FINDINGS-STABLE-CACHE-IDENTITY.md). + +## Planned Transform Follow-Ups + +### Mixed-directive Composition + +Research whether Next.js processes module-level `"use server"` and inline `"use cache"` through one shared traversal, which directive combinations are legal, and how wrapping, hoisting, export generation, and validation interact. Compare that output with applying independent Vite `"use server"` and `"use cache"` transforms in different orders, including whether the first transform hides directives or exports needed by the second. A representative real-world case is [`app/[locale]/category/actions.ts:19`](https://github.com/vercel-partner-solutions/the-platform-press/blob/0fdee98ad98766f36baf948a48d0df5705b27811/app/%5Blocale%5D/category/actions.ts#L19). + +Deliverable: `FINDINGS-MIXED-DIRECTIVE-COMPOSITION.md` containing an equivalent fixture matrix, normalized one-pass and composed-pass outputs, transform-order failure modes, and a recommendation on whether Vite needs one shared traversal, a directive-neutral intermediate representation, or stricter composition contracts between existing transforms. + +This track excludes cache storage behavior and broad syntax coverage unless a syntax form demonstrates a composition problem. + +### Broader Transform Surface + +Research source forms deferred from the first comparison: named and default exports, module-level directives, object and class methods, computed and optional member access, destructuring, shadowing, nested closures, and capture-path reduction. Compare semantic support rather than incidental formatting. + +Deliverable: `FINDINGS-BROADER-TRANSFORM-SURFACE.md` containing a Next.js versus Vite/PR #1246 capability matrix, focused input/output fixtures for each materially different form, classification of unsupported forms as transform limitations or intentional exclusions, and the smallest generic-hoister changes needed for parity. + +This track excludes diagnostics except where generated output is unsound without rejection, and it excludes cross-environment transport after confirming that the correct callable shape is emitted. + +### Transform Validation + +Research validation only after the mixed-directive and broader-surface semantics are established. Inventory checks that protect transform invariants, such as unsupported `this`, `super`, or `arguments` capture, illegal directive combinations, non-async functions, invalid method forms, and client-boundary restrictions. Do not pursue message wording parity without a behavioral reason. + +Deliverable: `FINDINGS-TRANSFORM-VALIDATION.md` containing an invalid-input fixture matrix, the invariant protected by each error, current Next.js and Vite behavior, missing checks that otherwise produce invalid or misleading output, and a minimum validation contract for the public transform helpers. + +This track excludes framework cache policy validation and comprehensive diagnostic text matching. + +## Planned Integration Follow-Up + +### Server-reference Transport + +Research cross-environment server-reference registration and protected bound arguments for module-level cached exports imported by Client Components and inline cached closures passed through Flight. Focus on which wrapper, identity, parameter shape, and capture metadata must be produced by the transform versus which proxy, manifest, encryption, and resolution steps can reuse existing `"use server"` orchestration. + +Deliverable: `FINDINGS-CACHE-SERVER-REFERENCE-TRANSPORT.md` containing normalized Next.js and Vite pipelines, module and inline fixture comparisons, exact wrapper-registration and encrypted-binding order, a transform-versus-orchestration responsibility matrix, an assessment of whether PR #1246 supplies the complete transform ABI, and the smallest integration API or composition change for any demonstrated gap. End with a narrowly specified development-and-build E2E proof rather than implementing the cache runtime in the research note. + +This track excludes cache result serialization, replay, storage, invalidation, and handler policy unless one requires additional transform-produced information. + +## Research Order + +1. Mixed-directive composition, because it determines whether later work can assume independent transforms are composable. +2. Server-reference transport, because it exercises the generated identity, wrapped export, parameter metadata, and protected capture boundary together. +3. Broader transform surface, once the composition architecture is understood. +4. Transform validation, after supported semantics and intentional exclusions are known. + +## Excluded Runtime Observations + +Both implementations perform Flight argument encoding, temporary-reference management, pending-call deduplication, result-stream serialization, and replay in runtime code. The focused inspection found no additional transform information required for those techniques once each runtime has its chosen function identity and argument list. They were therefore excluded from the capability comparison. + +Next.js cache life, tags, prerender stages, custom handler policy, resume data, and invalidation are also outside this transform-derived investigation. + +## Verification + +The findings use existing transform snapshots and direct source inspection. No repository files were modified and no test suites were run because the relevant emitted forms are already snapshot fixtures in both repositories. diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/README.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/README.md new file mode 100644 index 000000000..f2ea59513 --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/README.md @@ -0,0 +1,168 @@ +# `use cache` Transform Comparison Plan + +- Repo: git@github.com:vitejs/vite-plugin-react.git +- Commit: 32611a28365435d81a513c559715411bdc8f127e +- Branch: main +- Next.js repo: git@github.com:vercel/next.js.git +- Next.js commit: 153bf8ac5fa00888ef5fbb2b65cac12f0942a44f +- Next.js branch: canary + +## Published Research + +- [FINDINGS.md](./FINDINGS.md): completed first-pass transform comparison. +- [FINDINGS-STABLE-CACHE-IDENTITY.md](./FINDINGS-STABLE-CACHE-IDENTITY.md): completed stable-identity follow-up. +- [VINEXT-CONTEXT.md](./VINEXT-CONTEXT.md): completed Vinext compatibility context. +- [SERVER-FUNCTION-EXTENSIBILITY.md](./SERVER-FUNCTION-EXTENSIBILITY.md): focused implementation analysis and API proposal. +- [VINEXT-SERVER-REFERENCE-PRESERVATION.md](./VINEXT-SERVER-REFERENCE-PRESERVATION.md): completed cache-replay preservation analysis. + +Each document is a research snapshot tied to the repository commits listed in its header. Later implementation changes do not rewrite the historical observations unless a note explicitly says otherwise. + +## Goal + +Compare the transform approach used by the minimal `"use cache"` demo in `@vitejs/plugin-rsc` with the approach used by Next.js. The primary question is what information each transform makes available to its runtime and which relevant RSC cache features are consequently enabled or constrained. + +Runtime implementation is evidence only when it consumes transform-produced information or demonstrates that missing transform information blocks a feature. A difference implemented entirely with runtime-only RSC techniques is not a transform difference and is outside the main investigation. + +This note records the plan after a high-level skim. Its observations are preliminary rather than conclusions from a complete implementation trace. + +## Initial Code Map + +The demo Vite plugin detects source containing `use cache`, parses it, applies the generic inline-directive hoister, and injects the example cache runtime in [packages/plugin-rsc/examples/basic/vite.config.ts:338](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/vite.config.ts#L338). + +The reusable transform is [packages/plugin-rsc/src/transforms/hoist.ts:13](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.ts#L13). It finds directive-bearing functions, hoists them, gathers closure references, turns captured values into leading parameters, wraps the hoisted function with a supplied runtime expression, and binds captured values at the original declaration site. + +Relevant transform snapshots begin at [packages/plugin-rsc/src/transforms/hoist.test.ts:420](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/transforms/hoist.test.ts#L420). Existing coverage includes `noExport` and directive-pattern handling. + +The demo runtime ABI consumer begins at [packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx:20](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx#L20). Inspect it only to establish what the generated wrapper passes to the runtime and which transform distinctions it can observe. + +Representative demo behavior is in [packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx:39](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx#L39), covering a normal function, a component with dynamic children, and a closure. + +Next.js implements `"use cache"` as part of its SWC server-actions transform in [crates/next-custom-transforms/src/transforms/server_actions.rs:283](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L283). + +Runtime and registration imports are emitted around [crates/next-custom-transforms/src/transforms/server_actions.rs:2582](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/src/transforms/server_actions.rs#L2582). + +A representative nested closure output is [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/40/output.js:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/40/output.js#L1). It shows the emitted callable shape, generated reference ID, explicit inner function, closure-bound argument representation, and runtime call. + +A representative custom cache-kind output is [crates/next-custom-transforms/tests/fixture/server-actions/server-graph/38/output.js:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/38/output.js#L1). + +The Next.js runtime ABI consumer begins at [packages/next/src/server/use-cache/use-cache-wrapper.ts:1612](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1612). Its wider cache implementation is not part of the comparison unless a specific behavior depends on the transform call shape. + +## Preliminary Architectural Difference + +The demo performs a generic rewrite whose essential shape is `runtime(hoistedFunction).bind(null, capturedValues...)`. The runtime receives the hoisted function, while closure captures are subsequently attached through ordinary JavaScript binding. + +Next.js emits a framework-specific wrapper whose runtime call carries a cache kind, generated reference ID for the examined build, bound-argument count, inner implementation, and bound captures as distinct inputs. + +The investigation should test the consequences of this representational difference. It should not assume that surrounding Next.js infrastructure is required merely because it appears in the same output or runtime module. + +## Related Context + +- [FINDINGS.md](./FINDINGS.md) contains the completed first-pass comparison. +- [FINDINGS-STABLE-CACHE-IDENTITY.md](./FINDINGS-STABLE-CACHE-IDENTITY.md) expands the generated-identity follow-up while keeping runtime cache policy secondary. +- [VINEXT-CONTEXT.md](./VINEXT-CONTEXT.md) explains Vinext's compatibility target where `"use cache"` implicitly also has `"use server"` transport semantics, how it differs from the server-local demo model, and why it activates the cross-environment server-reference follow-up. +- [SERVER-FUNCTION-EXTENSIBILITY.md](./SERVER-FUNCTION-EXTENSIBILITY.md) documents how Vinext #2156 uses the generalized #1246 transforms and proposes a framework-neutral server-reference registration API. +- [VINEXT-SERVER-REFERENCE-PRESERVATION.md](./VINEXT-SERVER-REFERENCE-PRESERVATION.md) explains why plugin-rsc's opaque replay option is a Vinext implementation choice rather than a Next.js compatibility requirement. + +## Research Plan + +### 1. Build A Focused Fixture Matrix + +Use equivalent small inputs for both transforms: a plain local async function, a function with explicit arguments, one closure capture, nested closures, member-only object capture, and a cached component whose props test invocation-argument representation. + +Keep declaration-form and syntax breadth out of this matrix. Add a case only when it isolates a difference in callable representation, capture representation, identity, or runtime ABI. + +### 2. Compare Generated Representation And Runtime ABI + +Record exact generated output or normalized pseudocode for each fixture. Reduce each output to: + +- The transformed callable and inner implementation shape. +- The point where the runtime wrapper is created. +- The representation and ordering of closure captures. +- The representation of invocation arguments. +- The generated identity and metadata passed to the runtime. +- The behavior of nested transformed functions and repeated wrapper creation. + +Avoid starting with broad Next.js end-to-end suites because transform fixtures expose this contract more directly. + +### 3. Inventory Transform-Provided Information + +Build a compact table showing whether each implementation exposes the following information explicitly, leaves it inferable from JavaScript behavior, or loses it before the runtime call: + +- Per-definition identity during the loaded module lifetime. +- Cache kind. +- Closure capture count and ordering. +- The boundary between closure captures and invocation arguments. +- A separately addressable inner implementation. +- Generated reference metadata present in the output, without evaluating its cross-environment use. + +Trace runtime code only far enough to confirm which entries in this inventory it consumes. Do not compare how the runtime implements serialization, caching, or replay after consuming them. + +### 4. Map Transform-Constrained Capabilities + +For each candidate capability, first identify the transform-produced information it requires. Include the capability in the comparison only when that dependency is demonstrated. + +The initial candidates are: + +- Correct identity when wrappers are recreated within one module lifetime. +- Runtime treatment of closure captures separately from invocation arguments. +- Cache-kind dispatch. +- Nested cached functions and nested capture binding. +- Runtime access to an unwrapped inner implementation. + +Classify each capability as supported directly, possible through runtime-only changes, requiring an extended transform ABI, or unsupported by the current representation. A feature missing from the demo runtime is not evidence of a transform limitation. + +### 5. Confirm Uncertain Transform Facts + +Use existing transform snapshots first. Add or propose a narrow fixture only when generated output or runtime ABI consumption remains uncertain. Do not expand confirmation into broad runtime or end-to-end behavior testing. + +### 6. Produce The First Research Output + +Deliver: + +- A concise pipeline diagram for each transform. +- Annotated generated output for the focused fixtures. +- The transform-information inventory. +- A capability matrix containing only demonstrated transform dependencies. +- The earliest transform-level constraint behind each material difference. +- The smallest transform ABI extension that could remove each important constraint without importing unrelated Next.js policy. + +Classify recommendations as: preserve the minimal design, document an intentional difference, harden the generic hoister, extend the demo transform ABI, or defer because the behavior is framework-specific. + +## Follow-Up Investigations + +These topics are related but should not expand the first pass unless a core semantic dependency requires them. + +### Stable Identity Beyond A Module Lifetime + +Compare source and build hashes, argument masks, manifests, code-change invalidation, deployment boundaries, persistent entries, distributed handlers, module reloads, and HMR. This deserves a separate investigation because it depends on build-system and storage policy beyond the current in-memory demo. + +### Cross-Environment Server References + +Investigate `registerServerReference`, client-layer proxies, export annotations, protected bound arguments, and whether cached callables need to cross an RSC boundary. The current demo invokes cached functions inside the RSC environment, so this is not required to explain its existing behavior. + +### Broader Transform Surface + +Compare named and default exports, arrow functions, file-level directives, methods, module-scope variants, computed access, optional access, destructuring, shadowing, and other declaration or capture forms. Include these when considering a supported general-purpose transform rather than the semantics of the current example. + +### Validation And Diagnostics + +Inventory validation after the semantic work. Focus on checks that prevent unsound transforms or protect runtime invariants, such as unsupported closure forms and client-boundary restrictions. Skip comprehensive typo and diagnostic-message comparison unless validation itself becomes the subject of later work. + +### Next.js Cache Policy + +Study cache life, tags, stale/revalidate/expire metadata, prerender stages, request APIs, custom handlers, resume data, hanging-fill diagnostics, and invalidation as independent framework features. + +## Explicit Exclusions + +Exclude Flight argument encoding, temporary-reference set mechanics, result-stream serialization and replay, console replay, pending-call deduplication, and cache storage mechanics when they require no additional generated information. Differences in these techniques are not evidence of a transform limitation. + +Do not treat the current demo as a proposed public `@vitejs/plugin-rsc` API. It is configured inside `examples/basic` and currently demonstrates composition of the generic directive hoister with an application runtime. + +## Expected Outcome + +The first write-up should answer four questions: + +1. What callable representation and runtime ABI does each transform emit? +2. What information does the Next.js transform provide that the generic hoist-and-bind transform does not? +3. Which relevant capabilities are demonstrably constrained by those information differences rather than merely absent from the demo runtime? +4. What is the smallest transform ABI change needed to remove each important constraint? diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/SERVER-FUNCTION-EXTENSIBILITY.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/SERVER-FUNCTION-EXTENSIBILITY.md new file mode 100644 index 000000000..0f9d8f740 --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/SERVER-FUNCTION-EXTENSIBILITY.md @@ -0,0 +1,237 @@ +# Userland Server-Function Extensibility + +## Goal + +Allow a framework-owned transform to make another directive produce React Server Functions without teaching `@vitejs/plugin-rsc` the directive's framework semantics. + +The motivation comes from the semantics of Next.js `"use cache"`: selected framework-owned callables can participate in React Server Function transport. The plugin-rsc API should not recognize `"use cache"`, implement caching, or make the basic demo align with Next.js. + +## Primary Direction + +The primary plugin-rsc API problem is bundler-level Server Function extensibility. An independently transformed callable should be able to opt into React Server Function transport through: + +- Normalized reference identity. +- Owner-aware metadata registration and cleanup. +- RSC registration and browser/SSR proxies. +- Protected bound arguments. +- Production manifest publication and development resolution. +- Stable composition with the built-in `"use server"` owner. + +Its question is: + +> How does an already transformed callable become a resolvable React Server Function? + +This capability depends on plugin-rsc's environment graphs, normalized module identity, manifests, development loading, and metadata lifecycle. It cannot be reproduced cleanly as an isolated syntax transform. It should not prescribe how the callable was produced or require a directive at all. + +## Current Built-In Boundary + +`vitePluginUseServer` currently owns both syntax policy and the full server-reference lifecycle in [packages/plugin-rsc/src/plugin.ts:1987](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/plugin.ts#L1987): + +- Detect the literal `"use server"` directive. +- Expand module-level `export *` declarations. +- Normalize module identity for development and production. +- Transform RSC implementations and register their exports. +- Encrypt and decrypt inline closure captures. +- Generate browser and SSR proxies for module-level directives. +- Publish exports through `RscPluginManager.serverReferenceMetaMap`. +- Remove stale metadata when directives disappear. +- Generate the production server-reference loader manifest. + +Several transform helpers are already public through `@vitejs/plugin-rsc/transforms`, but identity normalization and lifecycle ownership remain private to `vitePluginUseServer`. + +## Semantic Requirement + +Next.js-compatible cached functions need React Server Function protocol semantics when they cross into Client Components. They do not need the literal `"use server"` directive or the built-in `vitePluginUseServer` transform. + +The protocol-level requirements are: + +- A stable reference key and exported name. +- Registration of the callable that should execute remotely. +- Client and SSR proxies for directly imported module exports. +- Manifest and development-loader discoverability. +- Protected bound closure arguments for inline references. + +Framework-specific cache behavior remains outside plugin-rsc: + +- Choosing which directives imply remote callability. +- Wrapping the inner implementation with the cache runtime. +- Ensuring registration targets the cached wrapper rather than the raw function. +- Using parameter metadata to exclude framework-supplied action arguments from cache keys. +- Cache kind, lifetime, storage, and invalidation policy. + +Cached Flight replay is also outside this requirement. Preserving opaque references while replaying a cached Flight stream is a separate runtime choice, not a prerequisite for implementing a userland cached Server Function directive. + +## Core Implementation Change + +The fundamental problem is that `serverReferenceMetaMap[id]` is treated as the output of one transform. In reality, server references for one module can be discovered by different producers and in different Vite environments. A transform that finds nothing currently deletes the entire module record, including references discovered elsewhere. + +Replace the single record with reference claims keyed by module, producer, and environment. Each transform pass replaces only its own claim. The effective module metadata is the union of live claims. + +The environment dimension is necessary even for built-in `"use server"`: an RSC pass can discover inline references while a client or SSR pass correctly finds no module-level proxy. The latter must not erase the former. A client-only import can conversely discover a module-level server export that was not already reached through the RSC graph. + +The registry must also own canonical module identity. Development claims need the same RSC-environment Vite import URL, while production claims need the same root-relative hash. Claims for one module must agree on that identity; export names can be deduplicated, while conflicting producers for the same `referenceKey#exportName` should fail. + +`vitePluginUseServer` can keep all literal `"use server"` detection and transformation logic. Its existing assignments and deletes become updates to the built-in producer's current-environment claim. Production manifest generation iterates aggregated claims, and development validation checks the same aggregate. No generic directive pipeline or transform-provider abstraction is required. + +### Built-In `"use server"` On The New Foundation + +The built-in plugin would create one stable owner and replace only that owner's claim for the environment currently being transformed: + +```ts +const useServerOwner = manager.serverReferences.createOwner('rsc:use-server') + +async function transform(code, id) { + const module = manager.serverReferences.resolveModule(this, id) + + if (this.environment.name === rscEnvironmentName) { + const result = transformServerActionServer(code, ast, { + runtime: (value, name) => + registerServerReference(value, module.referenceKey, name), + // existing encryption and validation options + }) + + useServerOwner.replace(this.environment.name, module, { + exportNames: result ? getExportNames(result) : [], + }) + + return result?.output + } + + const result = hasModuleDirective + ? transformDirectiveProxyExport(ast, { + runtime: (name) => createServerReference(module.referenceKey, name), + }) + : undefined + + useServerOwner.replace(this.environment.name, module, { + exportNames: result?.exportNames ?? [], + }) + + return result?.output +} +``` + +An empty client or SSR claim no longer removes inline references found by the RSC pass. A module-level export discovered from a client-only import can still contribute a claim even when the module was not previously reached through the RSC graph. A custom producer can contribute different exports for the same module without either producer restoring metadata after the other runs. + +The aggregated entry remains the shape needed by existing consumers: + +```ts +{ + importId: module.importId, + referenceKey: module.referenceKey, + exportNames: unionOfLiveClaims, +} +``` + +Thus the production manifest and development validation need only switch from reading the mutable map to reading aggregated entries; the actual `"use server"` source transform and runtime protocol remain intact. + +## Smallest First-Class API + +The initial public surface only needs to let a userland transform obtain canonical identity and replace its own claim: + +```ts +const references = manager.serverReferences +const module = references.resolveModule(context, id) + +references.replaceClaim(owner, context.environment.name, module, { + exportNames, +}) +``` + +Replacing a claim with no exports performs cleanup for that producer and environment without deleting other claims. Manifest generation and development validation remain internal consumers of the aggregated view. The exact names are illustrative and should follow the implementation rather than drive it. + +## Optional Higher-Level Helper + +Once the claim-based lifecycle works for built-in `"use server"` and the dedicated userland example, plugin-rsc can consider extracting a higher-level helper from `vitePluginUseServer`. That helper could reuse environment branching, registration and proxy generation, encryption wiring, runtime imports, and claim cleanup while leaving syntax detection and framework wrapping to userland. + +Conceptually: + +```ts +createServerFunctionPlugin({ + name, + matches, + transformServer, + transformProxy, +}) +``` + +This could become a plugin factory or equivalent registration facility, with built-in `"use server"` eventually using the same path. The exact shape should be derived from the working implementation and E2E rather than designed first. It remains a desirable endpoint because userland frameworks should not need to reproduce the stable Server Function lifecycle after the lower-level ownership problem is solved. + +Per [plugin-rsc's testing guidance](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/CONTRIBUTING.md#end-to-end-tests), this feature should have its own runnable example with a thin development-and-production E2E demonstrating a simple userland-transformed cached callable crossing the Client Component boundary through the new registration API. Test the observable bundler behavior rather than reducing metadata ownership and proxy orchestration to surgical mocked tests; reserve unit tests for self-contained transform helpers. + +## Secondary Transform Investigation + +Separately, a Next.js-level cache runtime benefits from richer information at the transform boundary. This work is lower-level and can live entirely in userland; it is not a prerequisite for the first-class Server Function registration API. + +The investigated transform payload is: + +- `parameters: { count, hasRest }` for admitting the source function's positional invocation arguments into a cache key. +- `hasBoundArgs` for distinguishing protected closure captures from ordinary invocation arguments before entering a wrapper. +- Stable generated hoist names so unrelated source insertions do not unnecessarily change exported reference names. +- `exportWrappedHoist` so a remotely resolved export targets the framework wrapper rather than bypassing it for the raw implementation. +- Separate `moduleRuntime` and `inlineRuntime` hooks because module exports and closure-bearing inline functions require different wrapping positions. +- Export metadata, filtering, async validation, and custom directive matching for framework policy. + +[vite-plugin-react PR #1246](https://github.com/vitejs/vite-plugin-react/pull/1246) packages these capabilities into the public transform helpers, including a generalized `transformServerActionServer`. Upstreaming them is a reuse and maintenance choice rather than a bundler architecture requirement. The detailed transform comparison remains in [FINDINGS.md](./FINDINGS.md). + +## Recommended Work + +The two layers can land and be evaluated independently, but the server-function layer is the more important plugin-rsc design work. + +### Primary Plugin-rsc Work + +1. Replace the single module metadata record with producer- and environment-scoped claims. +2. Move built-in `"use server"`, manifest generation, and development validation onto the aggregated view. +3. Add the dedicated example and E2E coverage described above. +4. Expose canonical identity and claim replacement to the example's userland transform. +5. Validate that the same primitive removes Vinext's metadata restoration and copied identity logic. +6. Derive a cleaner higher-level Server Function helper from the proven shared lifecycle. + +### Optional Transform Tooling + +1. Land the generalized transform primitives from #1246. +2. Validate their runtime-neutral contracts with focused transform fixtures. +3. Keep cache policy and server-reference registration outside these helpers. +4. Treat upstream ownership as optional because a framework can maintain equivalent transforms in userland. + +This split solves the concrete ownership bug without making richer transforms imply transport or prematurely committing plugin-rsc to a broad custom-directive policy API. + +## Non-Goals + +- Hardcode `"use cache"` in plugin-rsc. +- Make the basic plugin-rsc cache demo match Next.js. +- Require every custom directive to become remotely callable. +- Treat cache replay as part of server-function registration. +- Expose the mutable metadata map as the long-term API. + +## Reference Context + +The API and E2E target above stand on their own. The following projects explain the original semantic motivation and demonstrate downstream demand, but they should not determine the plugin-rsc API shape. + +### Next.js `"use cache"` + +Next.js provides the source behavior: selected cached callables can be imported into Client Components or passed across the RSC boundary and invoked through Server Function transport. That establishes the motivating capability without requiring plugin-rsc to implement Next.js cache policy. + +### Earlier In-Core Proposal In PR #1246 + +[vite-plugin-react PR #1246](https://github.com/vitejs/vite-plugin-react/pull/1246) originally included an in-core `vitePluginServerFunctionDirectives` implementation. The preserved commit [`142fb07`](https://github.com/vitejs/vite-plugin-react/commit/142fb07353d2b167559aaa99b49a953951d98d9e) is titled `feat(rsc): support custom server function directives`. + +In [the PR discussion](https://github.com/vitejs/vite-plugin-react/pull/1246#issuecomment-4686411317), James explained that the generic plugin started from the built-in `"use server"` behavior and that `"use server"` itself could eventually be routed through the same mechanism. He did not make that conversion in the initial proposal because it seemed risky before proving the generalized path. + +The PR was later narrowed by commit [`5a2fd75`](https://github.com/vitejs/vite-plugin-react/commit/5a2fd7519fa6cce09af04b8b5288ecb8d4a2ddc3), `refactor(rsc): keep custom directives external`, leaving only the low-level transform improvements. This history is the direct upstream precedent for reconsidering a first-class Server Function registration API, while the current proposal can start below the earlier directive-specific plugin abstraction. + +### Vinext PR #2156 + +[Vinext PR #2156](https://github.com/cloudflare/vinext/pull/2156) is downstream evidence that the composition need is real. It directly consumes the #1246 transform API, but it does not patch `rsc:use-server` or route `"use cache"` through the literal built-in transform. + +Instead, Vinext installs two independent plugins around the built-in plugin: + +```text +vinext:server-function-directives +rsc:use-server +vinext:server-function-directive-metadata +``` + +Vinext currently reproduces normalized-ID logic, runtime selection, proxy generation, encryption wiring, metadata ownership, cleanup, and merging. Its second plugin restores Vinext-owned metadata after `rsc:use-server` deletes or replaces the single `serverReferenceMetaMap` record. + +This implementation demonstrates that a userland transform can produce the right Server Function semantics. It also provides a downstream validation target after the first-class registry API and plugin-rsc-owned E2E coverage exist. diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-CONTEXT.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-CONTEXT.md new file mode 100644 index 000000000..be4a30eb4 --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-CONTEXT.md @@ -0,0 +1,359 @@ +# Vinext `use cache` Server-Function Context + +- Repo: git@github.com:vitejs/vite-plugin-react.git +- Commit: 32611a28365435d81a513c559715411bdc8f127e +- Branch: main +- Reviewed: 2026-07-23 +- Vinext PR #1871 head: 9f4b6ee48a36c538deb0140d5aef818265a2d13a +- Vinext PR #2156 head: 622f0b915156fbfcb03ca4f1596cc0d0f6725ebe +- Plugin-rsc PR #1246 head: 5a2fd7519fa6cce09af04b8b5288ecb8d4a2ddc3 +- Plugin-rsc PR #1289 merge: a3690f37480b43d63366272b57f9bac0d2377c3b + +## Purpose + +This note records the higher-level context from: + +- [cloudflare/vinext#1871](https://github.com/cloudflare/vinext/pull/1871) +- [cloudflare/vinext#2156](https://github.com/cloudflare/vinext/pull/2156) +- [vitejs/vite-plugin-react#1246](https://github.com/vitejs/vite-plugin-react/pull/1246) +- [vitejs/vite-plugin-react#1289](https://github.com/vitejs/vite-plugin-react/pull/1289) + +It refines the scope of [FINDINGS.md](./FINDINGS.md). The central distinction is between a server-local cache directive and a custom directive that a framework elects to expose through the complete RSC server-function lifecycle. + +## Bottom Line + +Vinext is not demonstrating that caching inherently requires server-reference semantics or that the generic hoister cannot implement server-local `"use cache"` behavior. + +Vinext is pursuing compatibility with an observed Next.js model that can be summarized as: `"use cache"` implicitly also has `"use server"` transport semantics. The function remains cache-wrapped, but it is additionally registered and transported as a server function, so a nested cached function can be passed to a Client Component and invoked through the server-function protocol. Plugin-rsc currently treats only the explicit `"use server"` directive as opting into that protocol. Its surrounding orchestration was not designed to let another directive imply the same transport role. + +The resulting gap is primarily an assumption and public-composition gap: + +- Generic transforms can discover, hoist, capture, and wrap custom directives. +- Built-in reference registration, normalized IDs, client/SSR proxies, encryption, manifest metadata, and metadata cleanup are specialized around `"use server"`. +- A framework that wants another directive to imply `"use server"` transport must reproduce or hook into that orchestration. + +This does not imply plugin-rsc should adopt Next.js's implicit rule for `"use cache"`. The more general question is how a framework can declare that one of its custom directives also implies server-function transport. + +## Next.js Behavior, Intent, And Documentation + +Next.js currently compiles cache functions through its shared server-actions transform and registers generated cache wrappers as server references. In implementation terms, `"use cache"` is effectively `"use cache"` plus implicit `"use server"` transport. + +This behavior is covered by more than the dedicated nested-function fixture: + +- The broad `use-cache` E2E suite imports exports from a module-level `"use cache"` file defined in [test/e2e/app-dir/use-cache/app/(partially-static)/imported-from-client/cached.ts:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/app/%28partially-static%29/imported-from-client/cached.ts#L1) directly into the Client Component in [test/e2e/app-dir/use-cache/app/(partially-static)/imported-from-client/client-component.tsx:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/app/%28partially-static%29/imported-from-client/client-component.tsx#L1). The shared client form invokes those functions through `useActionState` in [test/e2e/app-dir/use-cache/app/form.tsx:14](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/app/form.tsx#L14), and the spec verifies cache hits in [test/e2e/app-dir/use-cache/use-cache.test.ts:192](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/use-cache.test.ts#L192). +- The same suite creates named, anonymous, and arrow inline `"use cache"` functions and passes them to a Client Component in [test/e2e/app-dir/use-cache/app/(partially-static)/passed-to-client/page.tsx:9](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/app/%28partially-static%29/passed-to-client/page.tsx#L9). The same [client form invocation](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/app/form.tsx#L14) calls them, and the spec verifies cache hits in [test/e2e/app-dir/use-cache/use-cache.test.ts:216](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache/use-cache.test.ts#L216). +- The dedicated fixture defines a cached component containing nested inline cached functions and passes them as props in [test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/page.tsx:16](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/page.tsx#L16). Its [Client Component boundary](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/form.tsx#L1) invokes those props through `useActionState` in [form.tsx:12](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/form.tsx#L12), and the spec asserts the browser round trip in [test/e2e/app-dir/use-cache-with-server-function-props/use-cache-with-server-function-props.test.ts:26](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/test/e2e/app-dir/use-cache-with-server-function-props/use-cache-with-server-function-props.test.ts#L26). +- Compiler fixtures separately pin the rule. A module-level cache input in [crates/next-custom-transforms/tests/fixture/server-actions/client-graph/6/input.js:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/client-graph/6/input.js#L1) becomes client `createServerReference` proxies in [client-graph/6/output.js:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/client-graph/6/output.js#L1). An inline cache function in [server-graph/33/input.js:1](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/33/input.js#L1) becomes a registered cache wrapper in [server-graph/33/output.js:5](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/crates/next-custom-transforms/tests/fixture/server-actions/server-graph/33/output.js#L5). + +This establishes maintained implementation behavior across multiple source forms rather than behavior inferred from one isolated edge-case test. It does not establish server-function semantics as the product-level definition of `"use cache"`. The PR history instead shows that particular consequences of the shared implementation have been added or preserved intentionally: + +- [Next.js PR #71401](https://github.com/vercel/next.js/pull/71401), merged October 17, 2024, explicitly fixed cached functions so they could be imported and called from Client Components. +- [Next.js PR #72506](https://github.com/vercel/next.js/pull/72506), merged November 12, 2024, optimized cache-key argument admission specifically so a cached getter could be used with `useActionState` without React's action arguments causing cache misses. +- [Next.js PR #72969](https://github.com/vercel/next.js/pull/72969), merged November 20, 2024, described a grouped `api.product.fetch()` cached method as a realistic use case and called both `"use server"` and `"use cache"` forms server functions. +- [Next.js PR #81431](https://github.com/vercel/next.js/pull/81431), merged July 9, 2025, deliberately preserved nested cached functions passed to Client Components as server references even when their enclosing cached component is restored from a partial-static cache. +- [Next.js PR #94301](https://github.com/vercel/next.js/pull/94301), merged June 2, 2026, documented direct client calls to cached exports while deliberately limiting how the capability is presented. + +There is explicit documentation for part of this model. The `"Caching function output with use cache"` section presents network requests, database queries, and slow computations as intended cached functions in [docs/01-app/03-api-reference/01-directives/use-cache.mdx:436](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/docs/01-app/03-api-reference/01-directives/use-cache.mdx#L436). It then states that exports from a file carrying a cached directive can be imported into a Client Component and called directly, where they run on the server similarly to a Server Function, in [use-cache.mdx:458](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/docs/01-app/03-api-reference/01-directives/use-cache.mdx#L458). + +The implementation and public framing therefore point in different directions. Compiler and E2E tests show broad reuse of server-function machinery: file-level cached exports can become client-importable proxies, while inline and nested cached functions can become registered references passed to Client Components. The file-level condition in the documentation limits the direct-import example, not the underlying transport mechanism. + +However, [Next.js PR #94301](https://github.com/vercel/next.js/pull/94301) explicitly says, “Rather than introducing this as a pattern now, let's start with a callout.” The resulting documentation presents direct client calls as a “Good to know” capability and recommends calling cached functions on the server instead. + +The best-supported reading is that reusing server-function machinery is an implementation technique rather than a product-level definition of `"use cache"`. Individual consequences of that technique are now intentional, tested, and documented, but Next.js deliberately does not present `"use cache"` as a general `"use server"` primitive or recommended client-call pattern. + +Vinext is therefore targeting broader observed Next.js behavior, not simply implementing a publicly stated rule that every `"use cache"` function is a server function. This behavior also does not prove that every framework's `"use cache"` implementation requires server-reference semantics. + +## Two Semantic Models + +| Model | Server-local cache directive | Cache directive with implicit `"use server"` transport | +| ------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------------- | +| Execution | Called only while evaluating server code | May also be invoked through an RSC action request | +| Crosses Flight as callable reference | No | Yes | +| Generic hoist and runtime wrapper | Sufficient for the demonstrated core behavior | Necessary but insufficient by itself | +| Registered server reference | Not required | Required | +| Client and SSR proxy | Not required | Required for module-level imports | +| Production manifest entry | Not required | Required to resolve action requests | +| Protected bound captures | Runtime choice | Required by the selected server-function protocol/security model | +| Stable reference naming | Only needed according to cache lifetime policy | Needed according to reference and HMR lifecycle | + +The minimal plugin-rsc demo implements the first model. Vinext PRs #1871 and #2156 target the second model for Next.js compatibility. + +## Vinext Transform Evolution + +There are three useful snapshots of the Vinext approach. + +### 1. Before The PRs: Server-Local Wrapper Plugin + +Vinext main has a framework-owned `vinext:use-cache` Vite plugin. It detects module-level and inline cache directives and composes plugin-rsc's exported low-level transforms: + +- Module-level `"use cache"` uses `transformWrapExport` to wrap selected exports. +- Inline `"use cache"` uses `transformHoistInlineDirective` to hoist closures and wrap the hoisted implementation. +- The supplied runtime expression calls Vinext's `registerCachedFunction(value, id, variant, options)`. +- The cache runtime owns key construction, storage, cache policy, argument/result Flight serialization, and replay. + +Normalized module-level output: + +```js +export async function getData(arg) { + return value(arg) +} + +getData = registerCachedFunction(getData, moduleId + ':getData', cacheKind) +``` + +Normalized inline output: + +```js +function outer(capture) { + const getData = registerCachedFunction(H, moduleId + ':H', cacheKind).bind( + null, + capture, + ) + return getData +} + +async function H(capture, arg) { + return value(capture, arg) +} +``` + +This approach is fundamentally server-local. The wrapped function is useful while evaluating server code, but the transform does not make custom cached callables participate in plugin-rsc's complete server-reference lifecycle. It does not inherently create client/SSR proxies, normalized plugin-rsc reference IDs, production manifest entries, or a manifest-addressable wrapped-hoist export. + +This is the same general composition pattern as the minimal demo, with a much richer framework cache runtime and additional Next.js-specific handling. + +### 2. PR #1871: Promote Cached Callables To Server References + +PR #1871 began as a fix for the nested cached-function prop fixture and evolved through several approaches: + +- Register the wrapped cached callable with `registerServerReference` at the inline call site. +- Replace raw file paths with plugin-rsc-compatible normalized reference keys. +- Write hoisted exports into `serverReferenceMetaMap` so production action requests can resolve them. +- Run metadata restoration after `rsc:use-server` because the built-in plugin could clear custom entries. +- Export or reassign the wrapped cached callable so manifest resolution invokes the cache wrapper rather than the raw hoisted implementation. +- Encrypt closure-bound arguments before exposing the callable as a server reference. + +These fixes progressively reproduced pieces of plugin-rsc's built-in `"use server"` orchestration. + +The later PR #1871 form then moved toward the proposed in-core `serverFunctionDirectives` option from the earlier version of plugin-rsc PR #1246. Vinext would provide the directive match, validation, cache runtime, wrapper expression, and export filtering, while plugin-rsc would own hoisting, registration, proxies, normalized IDs, encryption, and manifest metadata. + +Normalized configuration intent: + +```js +rsc({ + serverFunctionDirectives: [ + { + directive: /^use cache.*$/, + wrap: ({ value, id, name, directiveMatch, parameters, runtime }) => + `${runtime}.registerCachedFunction(${value}, ${id + ':' + name}, ${kind}, ${parameters})`, + }, + ], +}) +``` + +That design let a custom directive opt into implicit `"use server"` transport through plugin-rsc, but it also placed generic directive orchestration inside plugin-rsc. + +### 3. PR #2156: Userland Server-Function Directive Orchestration + +Plugin-rsc PR #1246 was subsequently narrowed to low-level transform improvements and no longer proposes an in-core `serverFunctionDirectives` option. PR #2156 adapts Vinext to that direction. + +Vinext now owns a generic server-function-directive implementation in userland while consuming plugin-rsc primitives. It installs two plugins around the built-in `rsc:use-server` plugin: + +```text +vinext:server-function-directives + -> rsc:use-server + -> vinext:server-function-directive-metadata +``` + +The first plugin runs while the original module shape is intact and performs environment-specific transforms: + +- In the RSC environment, it uses `transformServerActionServer` with custom module and inline runtimes. +- For inline cache functions, it asks the transform for stable names and exported wrapped hoists. +- It uses parameter metadata to configure cache-key argument admission. +- It uses `hasBoundArgs` to insert a decrypting adapter before the cached wrapper. +- It registers the resulting wrapper as a server reference with plugin-rsc-compatible normalized identity. +- In client and SSR environments, it emits proxies for module-level cached exports. + +The second plugin restores Vinext-owned metadata after `rsc:use-server` has performed its own cleanup or rewriting. Vinext tracks ownership so stale proxy transforms do not resurrect references after a directive is removed. + +The cache-specific wrapper remains framework-owned: + +```js +registerCachedFunction(inner, frameworkCacheId, cacheKind, { + parameters, +}) +``` + +The server-function lifecycle around that wrapper is now also Vinext-owned, but composed from plugin-rsc's public manager and low-level transforms rather than maintained as ad hoc cache-only patches. + +### Summary Of The Change + +| Stage | Cache transform ownership | Server-function lifecycle ownership | Resulting model | +| ---------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Vinext main before PRs | Vinext plugin using plugin-rsc hoist/wrap utilities | Only built-in `"use server"` lifecycle exists | Server-local cached functions | +| PR #1871 intermediate | Vinext cache plugin, then proposed plugin-rsc directive definition | Initially patched in Vinext; later delegated to proposed in-core plugin-rsc API | Cached functions promoted to server references | +| PR #2156 latest | Vinext generic directive plugin using richer plugin-rsc primitives | Vinext plugins own registration, proxies, metadata, and ordering | A custom directive can imply `"use server"` transport without an in-core directive API | + +The cache runtime itself is not replaced by these PRs. The main transformation is that selected cached callables gain a second role as first-class RSC server functions. + +See [SERVER-FUNCTION-EXTENSIBILITY.md](./SERVER-FUNCTION-EXTENSIBILITY.md) for the focused implementation analysis and proposed plugin-rsc composition API. + +## Vinext's Failing Behavior + +The motivating Next.js fixture defines inline cached functions inside a cached component, passes them as props to a Client Component, and invokes them with `useActionState`. + +That requires an end-to-end chain: + +1. The inline directive transform hoists and wraps the function. +2. The wrapped cached callable becomes the server-reference target. +3. Flight serializes the callable as an opaque server reference. +4. The reference ID resolves through plugin-rsc's production manifest or development import path. +5. Bound closure captures survive the reference protocol. +6. The action request invokes the cached wrapper rather than bypassing it and calling the raw implementation. + +The old Vinext transform could perform step 1 but did not own the rest of the chain reliably. + +## Why The Existing Hoist Output Was Insufficient + +The current generic hoister exports the raw hoisted implementation while the wrapped callable remains at the original declaration site. That is appropriate for the built-in server-local demo. + +When `"use cache"` also implies `"use server"` transport, the manifest-addressable export must represent the wrapped callable. Otherwise an action request can resolve the raw implementation and bypass caching. Vinext's PR history explicitly moved from inline registration and later reassignment toward exporting the wrapped hoist itself. + +This makes the wrapper-placement distinction from [FINDINGS.md](./FINDINGS.md) materially important only after adopting the implicit-`"use server"` model. It was correctly not a demonstrated limitation for the server-local demo. + +## Required Information And Lifecycle + +Vinext's implementation identifies two categories of missing support. + +### Transform Information + +- `parameters: { count, hasRest }` so the cache runtime can admit the same positional argument prefix as the transformed function. +- `hasBoundArgs` so the integration knows whether the first bound slot is a protected capture payload that must be decoded before entering the cache wrapper. +- Stable generated hoist names so unrelated source insertions do not unnecessarily change reference names. +- An `exportWrappedHoist` form so the exported reference target is the cache wrapper rather than the raw implementation. +- Richer export metadata for module-level directive forms. + +These proposed primitives appear in plugin-rsc PR #1246. They directly corroborate the argument-admission and capture-boundary findings in [FINDINGS.md](./FINDINGS.md). + +### Plugin Orchestration + +- Generate reference IDs using plugin-rsc's normalized module identity. +- Register transformed exports in `RscPluginManager.serverReferenceMetaMap`. +- Produce client and SSR proxies for module-level directive exports. +- Preserve metadata owned by the RSC transform across non-owning SSR/client passes. +- Remove stale owned metadata when a directive disappears during HMR. +- Compose correctly around the built-in `rsc:use-server` transform, which has its own metadata cleanup and rewriting behavior. + +This orchestration is not a property of function hoisting. PR #2156 implements it as Vinext-owned plugins before and after `rsc:use-server`, using plugin-rsc's public manager and low-level transforms. + +## Relation To Existing Research + +The Vinext context generally matches the first-pass findings, with one important scope activation. + +### Confirmed Findings + +- Parameter admission is a real transform-output requirement. Vinext passes parameter shape into `registerCachedFunction` and slices cache-key arguments accordingly. +- Capture packaging alone is insufficient when the outer runtime must decode captures before keying. Vinext uses `hasBoundArgs` to emit a decrypting adapter around the cached wrapper. +- Wrapper creation at the original nested site is acceptable for server-local caching but not enough when the wrapped callable must also be the exported server-reference target. +- Function-object identity is sufficient for the in-memory demo, while reference naming and normalized module identity become relevant when the custom directive also implies `"use server"` transport. + +### Newly Activated Follow-Up + +The previously deferred cross-environment server-reference track is now the most relevant next investigation because it is the actual subject of Vinext's reported gap. + +This does not invalidate the first-pass conclusion. It introduces a second semantic model with additional requirements. + +## Cache Replay Is Separate + +See [VINEXT-SERVER-REFERENCE-PRESERVATION.md](./VINEXT-SERVER-REFERENCE-PRESERVATION.md) for the detailed history and comparison with Next.js. In particular, Vinext's current use of #1289 should not be treated as evidence that opaque preservation is required for compatibility. + +Plugin-rsc PR #1289 adds opt-in `preserveServerReferences` behavior when decoding cached Flight. This allows a replaying RSC runtime to preserve an opaque reference without importing its implementation. + +Vinext PR #2156 currently calls this API in its Flight replay path, but #1289 is not required to implement its userland `"use cache"` transform or Server Function registration. Removing or replacing that replay strategy would not change the transform. Next.js instead supplies a `serverModuleMap` during cache replay and resolves the referenced implementation. A plugin-rsc framework could choose the same strategy. + +The concerns should remain separate: + +- Transform and orchestration create a valid, resolvable server reference. +- Cache replay must either resolve that reference, as Next.js does, or optionally preserve it opaquely using #1289. + +## Next Research Direction + +The next research should ask: + +> If a framework declares that a custom directive also implies `"use server"` transport, what minimal plugin-rsc primitives let it compose that server-function lifecycle? + +It should not assume: + +> `"use cache"` inherently requires server-reference semantics. + +The recommended investigation is: + +1. Trace the Vinext nested cached-function fixture from source transform through browser action invocation. +2. Prove where exporting the raw implementation fails and where exporting the wrapped hoist fixes resolution and cached-invocation semantics. +3. Map `parameters`, `hasBoundArgs`, stable names, normalized IDs, and manifest exports to their exact consumers. +4. Separate low-level transform primitives from cross-environment plugin orchestration. +5. Evaluate whether plugin-rsc's public manager plus low-level transforms are an adequate composition surface, or whether a narrower reference-lifecycle helper is justified. +6. Treat replay as a separate runtime track. Vinext currently chooses the already merged #1289 preservation behavior, but reference resolution is also valid. + +## Next Step For Plugin-rsc Discussion + +The next step for the plugin-rsc author is a focused discussion with Vinext that separates Vinext's compatibility motivation from plugin-rsc API design. + +### 1. Establish Why Vinext Needs This Behavior + +Ask how much of Next.js's `"use cache"` server-function transport Vinext treats as required compatibility: + +- The documented module-level capability for Client Components to call cached exports. +- An intentional extension of that capability to every inline and nested cached function. +- Behavior required by applications encountered in the wild. +- Primarily part of Vinext's Next.js compatibility-percentage goal. + +The documentation explicitly supports direct client calls to module-level cached exports and frames cached functions as useful for database queries, network reads, and slow computations. The PR history shows that direct imports, `useActionState`, and nested references were deliberate compatibility targets. It also shows that Next.js does not yet want to introduce client invocation as a recommended data-fetching pattern. + +Next.js's intent does not determine whether Vinext should support the behavior. Vinext's motivation does affect how strongly plugin-rsc should treat it as a general framework requirement rather than a compatibility target. + +### 2. Discuss Composable Server-function Semantics Independently + +Regardless of what Next.js intends for `"use cache"`, plugin-rsc's server-function machinery may be too tightly coupled to the literal `"use server"` directive. + +The general design question is: + +> Can plugin-rsc's server-function lifecycle be composed by another opt-in directive without duplicating the built-in plugin's normalized IDs, proxies, protected bound arguments, manifest registration, and metadata lifecycle? + +The desired boundary should preserve these constraints: + +- `"use server"` remains the only built-in directive with server-function meaning. +- Custom directives remain server-local by default. +- A framework can explicitly declare that a custom directive also implies `"use server"` transport. +- The reusable API does not encode Next.js `"use cache"` policy. +- Low-level syntax transforms remain separable from cross-environment reference orchestration. + +### 3. Ask Vinext For The Minimum Missing Surface + +PR #2156 demonstrates that external orchestration is feasible, but it also reproduces substantial lifecycle code. Ask Vinext to classify that code into: + +- Essential server-reference lifecycle logic that any custom server-function directive would need. +- Vinext-specific cache wrapping and Next.js compatibility policy. +- Temporary workarounds for current plugin ordering or metadata ownership. +- Utilities that could remain framework-local if plugin-rsc documented stable composition points. + +This should reveal whether plugin-rsc needs only the low-level transform improvements in PR #1246, a few narrower lifecycle helpers, or a generic opt-in server-function directive facility. + +### Suggested Discussion Framing + +> I see two separate motivations. First, Next.js documents direct Client Component calls to module-level cached exports and tests the same transport for inline and nested cached functions. I would like to understand whether Vinext needs the full general rule because of real application usage, deliberate Next.js compatibility, or both. Second, independently of that answer, plugin-rsc's `"use server"` machinery may be too syntax-specific. It would be useful to identify the minimum reusable server-reference lifecycle primitives that let a framework declare that another directive also implies `"use server"` transport, without making that the default for custom directives. + +Do not begin the discussion from the premise that `"use cache"` should become a built-in plugin-rsc server-function directive. Begin from the narrower composability question and use the Vinext case as one concrete consumer. + +## Open Design Question + +The primary design decision is not whether plugin-rsc should recognize `"use cache"`. It is where optional custom server-function orchestration should live: + +- Entirely in framework integration code, using exported low-level transforms and manager access. +- In a small plugin-rsc helper that handles normalized IDs, reference metadata, proxies, and lifecycle without prescribing directive runtime behavior. +- In a generic in-core custom-directive plugin, provided it remains opt-in and does not make server-reference semantics mandatory for server-local directives. + +PR #1246 currently favors improved low-level primitives with framework-owned orchestration. PR #2156 demonstrates that this direction is feasible, although it requires substantial lifecycle code in Vinext. + +## Scope Guard + +Do not use Vinext's compatibility target as evidence that the minimal demo is incorrect. Treat server-reference transport as an intentional Next.js semantic, but do not generalize it into a universal requirement for every framework's `"use cache"` implementation. Keep transform metadata, reference orchestration, and Flight replay as three distinct layers. diff --git a/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-SERVER-REFERENCE-PRESERVATION.md b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-SERVER-REFERENCE-PRESERVATION.md new file mode 100644 index 000000000..09779cbc8 --- /dev/null +++ b/packages/plugin-rsc/docs/research/use-cache-transform-nextjs-comparison/VINEXT-SERVER-REFERENCE-PRESERVATION.md @@ -0,0 +1,138 @@ +# Vinext Server-Reference Preservation Analysis + +## Conclusion + +`preserveServerReferences` is not required for Next.js-compatible `"use cache"` behavior. + +Vinext currently uses the option when decoding cached Flight, so plugin-rsc PR #1289 is a dependency of that implementation. However, no Vinext failure or test demonstrates that opaque preservation is required. Next.js revives the referenced server module during cache replay and then reserializes the registered function as a Server Reference. + +The actual compatibility requirement is that a Server Reference survives cached Flight replay and remains invocable. Avoiding implementation-module revival is a separate plugin-rsc capability. + +## Relevant Changes + +### Original Vinext Failure + +[Vinext PR #1871](https://github.com/cloudflare/vinext/pull/1871) targets nested cached functions passed to Client Components and invoked through `useActionState`. + +The failures identified during its review were: + +- The transformed function used an incorrect, non-normalized reference ID. +- Its module and hoisted exports were absent from `RscPluginManager.serverReferenceMetaMap`. +- Consequently, the production `virtual:vite-rsc/server-references` manifest could not resolve the submitted action. +- The built-in `rsc:use-server` transform later deleted metadata contributed by Vinext. + +These are registration, identity, manifest, and metadata-lifecycle problems. They do not imply that cache replay must preserve references opaquely. + +### Preservation Appears Later + +[Vinext commit b8a116c](https://github.com/cloudflare/vinext/commit/b8a116cf61642fc9b02903ad619f701c67139771) introduced: + +```ts +createFromReadableStream(stream, { + serverReferences: 'preserve', +}) +``` + +The change was part of `refactor(use-cache): use plugin-rsc directive transforms`. Its commit message does not describe a failure caused by normal reference revival. + +The corresponding preservation implementation had been added to plugin-rsc minutes earlier in [vite-plugin-react commit 2ae42d1](https://github.com/vitejs/vite-plugin-react/commit/2ae42d1221f5eacaa89eec7e4d64fdaf39324149), initially as part of [plugin-rsc PR #1246](https://github.com/vitejs/vite-plugin-react/pull/1246). + +The feature was later isolated in [plugin-rsc PR #1253](https://github.com/vitejs/vite-plugin-react/pull/1253) and landed in narrower form as [plugin-rsc PR #1289](https://github.com/vitejs/vite-plugin-react/pull/1289). + +[Vinext PR #2156](https://github.com/cloudflare/vinext/pull/2156) only migrates the existing call to the landed API: + +```ts +createFromReadableStream(stream, {}, { preserveServerReferences: true }) +``` + +Therefore #2156 listing #1289 as an upstream dependency describes its current code path, not a requirement established by the Next.js compatibility target. + +## The Conflated Requirements + +Two different properties were treated as one: + +1. A cached Flight fragment containing a Server Reference must replay into a value that can be serialized to the browser and invoked later. +2. The replaying RSC runtime must not import the referenced implementation module. + +Next.js requires the first property but does not implement the second. + +Normal revival is sufficient when the implementation is registered correctly: + +```text +cached Flight + -> createFromReadableStream + -> server module map lookup + -> import registered implementation + -> recover registered Server Reference identity + -> serialize it into the enclosing Flight response +``` + +Opaque preservation instead creates a synthetic registered reference carrying the original ID without importing the implementation. That is a valid optimization or framework architecture, but it is not necessary for compatibility. + +## Next.js Behavior + +On cache replay, Next.js constructs a `serverConsumerManifest` containing `serverModuleMap` in [`use-cache-wrapper.ts:3325`](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L3325). + +It passes that manifest to `createFromReadableStream` in [`use-cache-wrapper.ts:3336`](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/use-cache/use-cache-wrapper.ts#L3336). React uses the map to resolve and load the Server Function implementation. Because the loaded export is already registered, rendering the decoded value into the outer RSC response serializes it as a Server Reference again. + +Next.js has no equivalent of plugin-rsc's `preserveServerReferences` mode. Plugin-rsc PR #1289 explicitly records this distinction in its description. + +## Plugin-rsc Behavior + +Plugin-rsc's default `createFromReadableStream` supplies a server manifest in [`packages/plugin-rsc/src/react/rsc/client.ts:30`](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/react/rsc/client.ts#L30). + +Without preservation, the manifest resolves references through `requireModule` in [`packages/plugin-rsc/src/core/rsc.ts:87`](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/core/rsc.ts#L87). Development imports the normalized Vite module URL, while production resolves through `virtual:vite-rsc/server-references`. + +With preservation enabled, `createServerManifest` prefixes the reference ID in [`packages/plugin-rsc/src/core/rsc.ts:100`](https://github.com/vitejs/vite-plugin-react/blob/32611a28365435d81a513c559715411bdc8f127e/packages/plugin-rsc/src/core/rsc.ts#L100). The loader recognizes that prefix and constructs an opaque reference without loading the implementation. Such a reference can be reserialized but deliberately cannot be invoked inside the replaying RSC runtime. + +PR #1289's E2E specifically tests the no-load property. It proves that default replay imports the implementation immediately while preserved replay delays the import until browser invocation. This validates the optional feature, not its necessity for Vinext. + +## Why The Original Rationale Does Not Transfer + +Plugin-rsc PR #1253 motivates preservation with a cache layer replaying a framework-owned action that should not need to import through the application bundler manifest. + +Vinext's nested cached functions are different: + +- Vinext intentionally registers their normalized IDs and exports in plugin-rsc's server-reference metadata. +- The production server-reference manifest must contain them so later action POSTs can resolve. +- Their implementation modules are therefore application-manifest-owned and available to normal replay. +- Preservation cannot replace correct registration because eventual invocation still requires the manifest entry. + +Once Vinext performs the registration work required by #1871 and #2156, the premise that replay cannot or should not resolve those modules no longer follows. + +## Test Coverage Gap + +Vinext's tests establish that transformed references serialize and that action POSTs can resolve and invoke them. They do not establish that preservation is required: + +- Development bypasses shared cache replay. +- No Vinext test asserts that referenced implementation modules remain unloaded during replay. +- Repeated action calls demonstrate caching of action results, but do not necessarily prove a cache hit replaying the enclosing component Flight. +- There is no paired test showing that default revival fails while preserved replay succeeds. + +The explicit no-module-load assertion exists in plugin-rsc's #1289 E2E because that test is designed to demonstrate preservation itself. + +## Recommended Verification + +Vinext should test the compatibility path without preservation: + +1. Remove `{ preserveServerReferences: true }` from cached Flight decoding. +2. Render and persist a cached Flight value containing a nested cached function. +3. Restart the production server so replay must resolve the reference through the generated manifest rather than reuse an already evaluated module. +4. Trigger a genuine cache hit and verify that the cached UI renders. +5. Invoke the replayed function from the browser and verify cache semantics. + +If this fails, the failure should be traced through normalized identity, `serverReferenceMetaMap`, generated manifest contents, or module loading. Preservation should not be used to bypass those failures because Next.js exercises normal revival. + +## Implication For Plugin-rsc API Design + +Server-reference preservation should remain outside the custom Server Function registration discussion. + +The relevant plugin-rsc extension surface is: + +- Normalized reference identity. +- Owner-safe metadata registration and cleanup. +- Server registration and client/SSR proxies. +- Development validation and production manifest publication. +- Bound-argument encryption and reference loading. + +These allow a userland directive to opt into Server Function semantics. Whether cached Flight resolves or opaquely preserves those references is an independent runtime policy.