fix(router): preserve embedded server actions across cache and prerender hits - #584
Merged
Conversation
…tion (dev+prod) Adds an e2e fixture and a dev+prod test pinning the three freshness axes of an inline "use server" action created inside a "use cache" server component: - captured render scope is frozen at cache-write (e.g. an article id) - the action body runs live per invocation (fresh async value) - request scope is live (cookies() session read in the body) The test exposes a production-only failure: on a cache HIT the embedded action 500s with "server reference not found" because a "use cache" module is dropped from the plugin-rsc server-references manifest during the multi-pass build. The fix follows in a subsequent commit on this branch.
…s from the server-references manifest An inline "use server" action created inside a "use cache" function 500'd with "server reference not found" on a production cache HIT. plugin-rsc's multi-pass build shares one serverReferenceMetaMap: the rsc scan adds the inline action, the ssr scan deletes any module without a file-level "use server", and the rsc build emits the eagerly-imported server-references manifest before the lazily-loaded route module is re-added -- so the entry never lands in the manifest. On a cache hit the cached value is deserialized without executing the body that would re-register the action at runtime, so React falls back to the empty manifest and throws. Dev and the cache MISS hid it (the body runs and self-registers). exposeActionId now captures inline-action entries during the rsc scan and re-asserts them at the real rsc build's buildStart, before the manifest virtual module is generated. This also makes deterministic the ordinary inline-action modules that previously survived only by load-order luck. Keyed by manager so concurrent project builds stay isolated; remove once the upstream plugin-rsc race is fixed. Consumer impact: a cached component embedding an inline action (e.g. a cached article list with per-item like buttons) now works in production on cache hits. Captured render scope is frozen at cache-write (correct for stable identities like an article id); the action body runs live with live request context. Covered by e2e/use-cache-inline-action.test.ts (dev + production).
…-rsc preserve Experimental checkpoint. Extends the inline-action-in-cached-value fix to the build-time caching mechanisms (Static/Prerender) and to cross-process use-cache hits, using @vitejs/plugin-rsc PR #1246 'preserve' (installed via pkg.pr.new). Adds: - hashServerRefs: server-side analog of hashClientRefs in the build-discovery temp server, rewriting registerServerReference dev-style ids to production hashes so a server-created action embedded in prerendered/static Flight resolves against the production manifest (src/vite/plugins/server-ref-hashing.ts, wired in router-discovery.ts). + hash-server-refs unit test. - segment-codec: serverReferences:'preserve' on cache/prerender deserialize so a hit re-serializes embedded references to the client instead of resolving them to raw functions React refuses to pass to a Client Component. - Static + parameterized Prerender embedded-action fixtures (prerender.tsx) + dev /prod e2e (prerender-inline-action.test.ts), shared inline-action helper. - EXPERIMENTAL defineEncryptionKey env wiring (rango.ts, router-discovery.ts) so build-time-encrypted bound args decrypt at runtime -- to be replaced by a real per-build key strategy. Status: Static embedded actions validated end-to-end (frozen capture + live body + live cookie). use-cache cross-process covered by preserve. Parameterized Prerender action re-render still hits 'No prerender data found' (layer 5, open). NOT shippable: depends on a pkg.pr.new preview + experimental enc-key wiring.
…r fallback Replaces the WIP env-var encryption-key hack and fixes the last layer so Static AND parameterized Prerender routes embedding a server-created inline action work end-to-end (with @vitejs/plugin-rsc preserve). Encryption key (src/vite/encryption-key.ts): plugin-rsc encrypts inline-action bound args with a per-plugin-instance random key. The build-discovery temp server (which renders Static/Prerender output) is a separate instance from the main build, so by default it encrypts prerendered bound args with a key the runtime never has -> decryptActionBoundArgs fails. We generate one per-build key (cached per process, overridable via RANGO_ENCRYPTION_KEY) and pass it as plugin-rsc's defineEncryptionKey to BOTH the main build rsc() (rango.ts) and the temp server rsc() (router-discovery.ts, build only) so build-time-encrypted bound args decrypt at runtime. Prerender action re-render (cache-lookup.ts): the prerender store was skipped for all action requests (!ctx.isAction), so an action re-render on a pure Prerender route fell through to the build-evicted handler and threw 'No prerender data found'. A pure (non-Passthrough) Prerender route has no live handler to render fresh on an action, so fall back to the prerendered entry (the action already ran; its result is applied client-side via useActionState). Passthrough routes keep a liveHandler and still re-render fresh on actions. Verified: use-cache + Static + parameterized Prerender embedded actions all pass dev+prod e2e; semantic matrix green (61); typecheck/lint/format/test:unit:all green. Still gated on plugin-rsc shipping preserve (pkg.pr.new today).
…cross-process use-cache (preserve) prerender-api-design.md: expand the Actions section with the four-layer mechanism (hashServerRefs, manifest re-assertion, preserve, shared encryption key) plus the pure-Prerender action-rerender fallback, and the plugin-rsc preserve dependency. use-cache-api-design.md: note same-process vs cross-process hit behavior and the preserve requirement for cross-process/shared-store embedded actions.
commit: |
ivogt
marked this pull request as ready for review
June 14, 2026 14:14
ivogt
marked this pull request as draft
June 14, 2026 14:29
Cleanup pass over the embedded-action changes (no behavior change): - segment-codec: extract one PRESERVE_SERVER_REFS const instead of repeating the serverReferences:'preserve' option + comment at the 3 deserialize sites. - cache-lookup: move the isPassthroughPrerenderRoute scan INSIDE the '!isHmr && ctx.matched.pr' guard so non-prerender requests skip it on the per-request hot path; drop the tautological allowActionPrerenderFallback intermediate (now plain '!ctx.isAction || !isPassthroughPrerenderRoute'). - server-reference-pattern.ts (new): single owner for the registerServerReference regex, previously duplicated byte-for-byte in server-ref-hashing and expose-action-id; returns a fresh /g RegExp per call (no shared lastIndex). - inline-action-helpers: shared buildInlineActionState() for the 3 identical inline-action bodies (use-cache + Static + Prerender fixtures). - e2e/inline-action.helpers.ts (new): shared fixture accessors/readers/setSession for the use-cache and prerender/static e2e suites (were duplicated). Verified: inline-action e2e dev+prod (31), semantic matrix (61), test:unit:all, typecheck, lint, format all green.
…e/workerd prerender) defineEncryptionKey was passed to the build-discovery temp server only when forceBuild was true. But under Cloudflare/workerd DEV the RSC env has no module runner, so prerender/static handlers are rendered by that Node temp server (via /__rsc_prerender), while the action's bound args decrypt in the main workerd runtime with rango.ts's key. With the gate, the dev temp server encrypted with its own random plugin-rsc key -> decryptActionBoundArgs failed on invocation in CF dev (Node dev renders prerender via the main registry, so it already shared the key and passed; build passed via forceBuild). Pass defineEncryptionKey: defineEncryptionKeyExpr() to the temp server unconditionally (process-cached, so all rsc() instances in the process share one key). hashServerRefs stays build-only: CF dev resolves the dev-style id by import() (plugin-rsc's dev branch), so no hash rewrite is needed there. Verified (adversarial workflow): enc-key is the only CF-dev gap -- id resolution (dev import) and re-serialize (preserve) already hold; a claimed id-resolution gap was a false positive (dev resolves by import, not the build hash manifest). Node dev+build inline-action e2e still green (no regression). Doc: prerender-api -design.md notes the CF-dev rationale and the RANGO_ENCRYPTION_KEY requirement for multi-instance / rolling-deploy production.
…erender) + blog (cache)
Extends the existing articles (build-time Prerender) and blog (runtime cache())
features with a shared inline "use server" like action, giving Cloudflare/workerd
e2e coverage for an embedded server-created action across BOTH caching mechanisms
-- the gap that left the CF-dev encryption-key bug unproven in CI.
- src/components/InlineLikeButton.tsx + src/inline-action-helpers.ts: shared
client button + action body (buildInlineActionState captures the per-item slug,
reads a live cookie, returns a fresh value).
- ArticleDetail (/articles/:slug, Prerender) and BlogPostPage (/blog/:slug,
cache({ttl,swr})) each create an inline action capturing their slug and render
the button.
- e2e/inline-action.test.ts (dev + production): the like button resolves on a
prerender hit (article) and a runtime-cache hit (blog), with a per-item frozen
captured slug, a live body (fresh value), and live request scope (cookie).
Verified: CF dev 3/3 (proves the temp-server encryption-key fix live), CF
production 2/2 (proves preserve + hashServerRefs + build key), build exit 0,
typecheck/lint/format/test:unit:all green. No new routes (no gen-file change).
2 tasks
Redundant on @1246: serverReferences:"preserve" masks the plugin-rsc manifest drop at runtime. Proper upstream fix: vitejs/vite-plugin-react#1251 (issue #1250). Refs #585.
13 tasks
Collaborator
Author
|
Reconfirmed against current main plus #768: runtime-captured PPR does not inherit this blocker. Closure-bound inline actions survive both document shell HIT hydration/invocation and partial PPR navigation/invocation in dev and production, in the router app and the Cloudflare/KV app. Coverage is in #769. This does not change #584's build-time Static/Prerender status or its dependency on vitejs/vite-plugin-react#1246. |
13 tasks
…action-server-ref # Conflicts: # package.json # packages/rangojs-router/docs/prerender-api-design.md # packages/rangojs-router/e2e/test-app/src/router.named-routes.gen.ts # packages/rangojs-router/e2e/test-app/src/urls/prerender.tsx # packages/rangojs-router/e2e/test-app/src/urls/use-cache-fn.tsx # packages/rangojs-router/src/router/match-middleware/cache-lookup.ts # pnpm-lock.yaml
ivogt
marked this pull request as ready for review
July 23, 2026 07:50
ivogt
marked this pull request as draft
July 23, 2026 07:50
ivogt
marked this pull request as ready for review
July 23, 2026 09:09
…_ENCRYPTION_KEY [skip ci] Review follow-ups for #584, docs and comments only: - use-cache-api-design.md and prerender-inline-action.test.ts described the exposeActionId serverReferenceMetaMap re-assertion that 79ff078 removed; they now describe the actual fix (loader scan stubs keeping implementation imports out of non-RSC scan builds + preserveServerReferences at decode), with upstream refs (vite-plugin-react #1250/#1251, preserve #1246). - Document RANGO_ENCRYPTION_KEY (use-cache-api-design.md, prerender-api-design.md, use-cache SKILL.md): per-build random default is inlined into the server bundle; persistent cross-process stores serving a previous deploy's entries fail decryptActionBoundArgs on embedded-action invocation unless the key is pinned across builds. - feature-file-map.md: list server-ref-hashing.ts, server-reference-pattern.ts, and encryption-key.ts; add the embedded-action narrative to the Pre-rendering row.
5 tasks
ivogt
added a commit
that referenced
this pull request
Jul 28, 2026
…p, manager API adaptation, react 19.2.8 (#816) Dev-mode client-reference dedup now resolves a deep third-party "use client" module to its PRECISE public subpath through the package's exports map (single-star patterns included, every candidate verified by resolving back to the identical file, cached per source) instead of rewriting to the bare package root — which silently dropped any symbol the root did not barrel-export. The root rewrite survives only as a documented lossy fallback when no public subpath maps to the file. expose-action-id adopts the public getPluginApi() and tolerates both plugin-rsc manager shapes (serverReferences.metaMap Map and the legacy serverReferenceMetaMap record), failing loudly with a named error if neither matches — groundwork for the #1310 claims API (pluggable server functions, released in 0.5.31, now the pinned catalog version with the quarantine exemption updated). react/react-dom peers and catalog move to 19.2.8. New e2e surfaces, dev + production in BOTH test-app and cloudflare-basic: client-package-resolution (rango-e2e-deep-context-lib fixture package with non-barrel deep exports pins the dedup rework, red case = NOT_FOUND context) and head-script-preload (dedicated playwright.preload.config.ts — isolated ports and vite cache, RANGO_E2E_HEAD_SCRIPTS=preload threads rango({ headScripts }) — pins the inline-bootstrap + modulepreload document shape), wired into root test:e2e and both CI matrix jobs. segment-codec tests pin the preserveServerReferences decoder option shipped in #584 via inspectable mocks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Embedding a server-created inline
"use server"action inside a cached or build-time-prerendered value can fail on the cache/prerender hit. The canonical case is an article list whose like action captures the article id but reads the current user from the live request:The required contract is:
Before And After
"use cache"embedded action, Node memory store"use cache"embedded action, Cloudflare cross-process storeStatic()embedded actionPrerender()embedded actionRoot Cause And Fix
A server-created action in stored Flight must survive
serialize -> store -> deserialize -> re-serialize -> invoke. Five pieces now line up:hashServerRefsrewrites its dev-style server-reference ids to the production hashes stored in the real manifest.createLoadermodules. Their server implementation dependencies then leak into non-RSC analysis. A dedicated build-only pre-scan plugin emits loader stubs first while leaving RSC and normal builds unchanged. Real Node and Cloudflare loader fixtures importserver-onlyto pin this ordering.segment-codecuses the third-argumentpreserveServerReferencesoption released in@vitejs/plugin-rsc@0.5.30, keeping references opaque instead of resolving them to raw functions that React refuses to pass to a Client Component.defineEncryptionKey, so bound arguments captured during prerender can be decrypted when the action runs.RANGO_ENCRYPTION_KEYsupports a stable deployment key.Prerenderroute falls back to its stored entry because its build-only handler no longer exists. Passthrough routes retain their live handler and still re-render fresh.The earlier
serverReferenceMetaMapre-assertion workaround has been removed. Keeping loader implementation imports out of the non-RSC scan fixes the ordering problem at its source.Main Changes
use cache,Static, parameterizedPrerender, Node loader, and Cloudflare/workerd fixtures.@vitejs/plugin-rscminimum to^0.5.30and removed the preview override.Verification
@vitejs/plugin-rsc@0.5.30migration: Node embedded-action matrix passed 6/6 in dev and production.@vitejs/plugin-rsc@0.5.30migration: Cloudflare/workerd embedded-action matrix passed 4/4 in dev and production.Dependency
This fix requires
@vitejs/plugin-rsc@^0.5.30, the first stable release withpreserveServerReferencessupport.Fixes #585