fix(use-cache): support nested cache functions passed as props - #1871
fix(use-cache): support nested cache functions passed as props#1871james-elicx wants to merge 16 commits into
Conversation
|
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
commit: |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
I dug into the @vitejs/plugin-rsc runtime to verify whether this approach can actually round-trip a server reference. It can't in production builds, and likely not in dev either. The fix targets the right problem but uses the wrong reference id, and never registers the module in the manifest the plugin resolves from. The linked Next.js test (/nested-cache) clicks a form button, which is a full client→server action round-trip — so resolution must work, not just serialization.
Blocking: the id passed to registerServerReference is not a resolvable reference key
The PR emits:
registerServerReference(cachedFnExpr, id, name)where id is the raw module id Vite hands to the transform hook (an absolute file path, possibly with a query). But the plugin's own "use server" transform does not use the raw id — it uses a normalized reference key:
// node_modules/@vitejs/plugin-rsc/dist/plugin-*.js
runtime: (value, name) =>
`$$ReactServer.registerServerReference(${value}, ${JSON.stringify(getNormalizedId())}, ${JSON.stringify(name)})`
// getNormalizedId():
// build: hashString(manager.toRelativeId(id))
// dev: normalizeViteImportAnalysisUrl(server.environments[server], id)When the client submits the form action, the id is posted back and resolved via loadServerAction(id):
// dist/core/rsc.js
async function loadServerAction(id) {
const [file, name] = id.split("#");
return (await requireModule(file))[name];
}In a production build, requireModule/setRequireModule.load resolves through the generated virtual:vite-rsc/server-references manifest, which is keyed only by meta.referenceKey (the normalized/hashed id):
// dist/rsc.js
const import_ = serverReferences[id];
if (!import_) throw new Error(`server reference not found '${id}'`);The raw path this PR passes will not be a key in that manifest, so clicking the button in production throws server reference not found '<id>'. vinext already maps exactly that error to a Next.js 404 (server/server-action-not-found.ts:77), so the test's expect(...).toMatch(isoDateRegExp) would fail.
Blocking: the module is never added to serverReferenceMetaMap
Even if the id were correct, the build-time server-references manifest is populated exclusively by the plugin's own "use server" transform writing manager.serverReferenceMetaMap[id]:
// dist/plugin-*.js — createVirtualPlugin("vite-rsc/server-references")
for (const meta of Object.values(manager.serverReferenceMetaMap)) {
const key = JSON.stringify(meta.referenceKey);
const id = JSON.stringify(meta.importId);
...
}vinext calling registerServerReference at runtime attaches $$typeof/$$id metadata to the wrapper, but it does not add an entry to serverReferenceMetaMap. So there is no manifest entry to import from in production — independent of the id-format issue above. Both must be solved for the round-trip to work in a build.
Dev is also suspect, not just prod
In dev the reference key is normalizeViteImportAnalysisUrl(...), not the raw transform id. So the registered id and the id the consumer resolves are different shapes, meaning the round-trip likely fails in dev too. If the preview deploy appears to "work," please verify the actual /nested-cache button click renders a date/random rather than 404 — serialization succeeding is not the same as the action resolving.
Direction worth investigating
The robust fix is to make these inline cache functions go through the plugin's own server-reference machinery (so they land in serverReferenceMetaMap with the correct referenceKey), rather than calling registerServerReference manually with a hand-rolled id. Two avenues:
- Let
@vitejs/plugin-rsc'stransformServerActionServer(or itsuseServertransform) also process these hoisted cached functions so the manifest entry + normalized key are produced by the plugin, then layer the cache wrapper on top. - If vinext must register manually, it needs to (a) compute the same normalized reference key the plugin uses for both dev and build, and (b) ensure the module is included in the server-references manifest. That's effectively reimplementing the plugin's bookkeeping and is fragile.
Process gaps
- No test added. This is required per
AGENTS.md("Add tests first"), and the linked Next.js test is a behavioral round-trip that a unit test alone can't fully cover. Please port the/nested-cachefixture (page + client form) intotests/fixtures/app-basicand add an E2E or integration test that asserts the date/random actually render after the click — not just that the page serializes. Without it, a green preview can mask a prod-only 404. - Branch is behind
main. It's missing several merged commits; please rebase so CI exercises the change against currentmain.
Minor
index.ts:4227-4232— the IIFE that resolves@vitejs/plugin-rsc/react/rscand theisRscEnvgating are fine, but if the manual approach is kept, hoist the resolution so it isn't recomputed per matched module, and confirm the resolved file URL matches whatcache-runtime.tsalready imports (@vitejs/plugin-rsc/react/rsc) to avoid loading two copies in separate module instances.
Summary
The serialization intent is correct, but as written the cached function cannot be resolved back on submission: the reference id isn't the plugin's normalized key, and the module is never registered in the build manifest. This will surface as a production 404 on form submit (the exact path vinext maps via server-action-not-found.ts). Recommend routing through the plugin's server-reference machinery and adding a round-trip test before merge.
|
Review posted to PR #1871. SummaryI reviewed the
The linked Next.js test ( I also flagged process gaps: no test was added (required by |
7051a7e to
741d366
Compare
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
This revision directly addresses both blockers from the previous review: it now derives the normalised reference key (instead of the raw absolute id) and registers the module in serverReferenceMetaMap so the build-time virtual:vite-rsc/server-references manifest includes it. I traced the @vitejs/plugin-rsc runtime again and the overall mechanics now line up:
- Dev:
loadServerAction(id)validates againstmanager.serverReferenceMetaMap(plugin-BK29Va7z.js:616) thenimport(file). The PR registers the entry and uses a Vite-importable/...-prefixed key, so validation + import resolve. - Build:
virtual:vite-rsc/server-referencesis generated fromserverReferenceMetaMapduring the real RSC build, step[3/5](plugin-BK29Va7z.js:1648-1665), and the PR writes into that same sharedmanagerinstance (correctly captured via thersc:minimal.api, matchinggetPluginApiatplugin-BK29Va7z.js:568).transformHoistInlineDirectiveemits realexport function $$hoist_N_…declarations (transforms/index.js:27), soexportNames: result.namesare genuine exports the manifest can re-import.
The direction is right. However there are still correctness and process gaps that should be resolved before merge.
Blocking-ish: build-mode key formula diverges from the plugin's toRelativeId
The plugin computes the build key as hashString(manager.toRelativeId(id)), where (plugin-BK29Va7z.js:553-555):
toRelativeId(id) { return normalizePath(path.relative(this.config.root, id)); }The PR re-implements this with a raw string slice (index.ts:4290-4296):
createHash("sha256")
.update(id.replace(/\\/g, "/").slice(projectRoot.replace(/\\/g, "/").length + 1))
.digest().toString("hex").slice(0, 12)Two divergences:
path.relativevsslice.path.relative(root, id)resolves/normalizes both operands (collapses./.., handles trailing slashes, drive-letter casing on Windows). A literalslice(root.length + 1)only matches whenidis exactlyroot + "/" + relwith no normalization differences. For the happy path they agree; for any non-canonicalidthey don't, and a mismatch producesserver reference not found '<key>'on submit.- Wrong root source.
toRelativeIdusesmanager.config.root(the shared/root config — set atplugin-BK29Va7z.js:582). The PR usesthis.environment?.config?.root ?? root(index.ts:4287-4288). These usually coincide, but the plugin is authoritative about which root it hashes against, so re-deriving from the environment root is a latent parity risk.
Since the PR already captures rscPluginApi.manager, the robust fix is to reuse the plugin's own helper rather than re-deriving:
const normalizedRefKey = isBuild
? hashString(rscPluginApi.manager.toRelativeId(id)) // identical to the plugin
: /* dev key */;That guarantees byte-for-byte parity with whatever the plugin would have produced, and removes the fragile slice.
Blocking (process): the added test doesn't test the implementation
tests/use-cache-server-ref-key.test.ts re-implements the key formula locally (buildNormalizedRefKey uses path.relative — tests/use-cache-server-ref-key.test.ts:29-32) and asserts that re-implementation against hashString. It never imports or exercises vinext's actual transform in index.ts. So:
- It can't catch a regression in the real code (and notably, it validates
path.relative, while the implementation uses aslice— the test would stay green even if the two diverge, which is exactly the bug surface above). - It does not cover the behavioral requirement at all. The linked Next.js test (
use-cache-with-server-function-props→ "should be able to use nested cache functions as props") is a client→server round-trip (button/form action). Serialization succeeding is not the same as the action resolving on submit.
Per AGENTS.md ("Add tests first", and the dev/prod-parity guidance), please add a fixture under tests/fixtures/app-basic that passes an inline "use cache" function as a prop to a client component which invokes it (e.g. via formAction/useActionState), and an E2E/integration test asserting the result actually renders after the interaction — not just that the page serializes. A green preview deploy can mask a prod-only 404 here, which is precisely the failure mode the previous review flagged.
Note: the resolved server reference is the uncached function
The hoisted export is the raw $$hoist_N function; the registerCachedFunction(...) wrapper is applied at the call site and then wrapped by registerServerReference. When the client submits the action, loadServerAction imports the module export — i.e. the uncached hoisted function (cache-runtime.ts:440 returns a separate cachedFn closure that is not what's exported). If the intent is that direct server-action invocation of a "use cache" function should still be cached, this won't do it. If Next.js treats the cached-fn-as-action as just "callable" on submit (cache applies during render, not on direct action invoke), this is fine — but please confirm against Next.js behavior and document the decision, since it's a real semantic difference.
Minor
- Heavy duplication. The
transformHoistInlineDirectivecall + the entireruntimecallback (directive-match/variant parsing) is copy-pasted across the RSC and non-RSC branches (index.ts:4318-4329vsindex.ts:4360-4372). Only theregisterServerReferencewrapping differs. Extract a sharedruntime/options builder so the variant-parsing logic can't drift between the two paths. rscReactRscUrlresolved per matched module (index.ts:4303-4306) — hoist this resolution (and the IIFE) out of the per-transform path; it's invariant for the build. Also confirm the resolved file URL matches the onecache-runtime.tsalready imports for@vitejs/plugin-rsc/react/rscso the RSC env doesn't load two copies in separate module instances.serverReferenceMetaMapcollision handling (index.ts:4335-4346): the merge path assumes vinext's entry and any plugin"use server"entry share the samereferenceKey. If a file had both, the last writer wins onreferenceKey; the merge only unionsexportNames. Edge case, but worth a comment that this can't currently happen (or a guard).- Branch is ~3 commits behind
main(missing #1881 etc.). Rebase so CI exercises against currentmain.
Summary
The approach is now sound in principle — correct normalised key + manifest registration — and clearly better than the prior revision. Remaining must-fix items before merge: (1) derive the build key via manager.toRelativeId instead of the fragile slice (and from the manager's root), and (2) add a real round-trip test/fixture for "nested cache function as prop" rather than a self-referential formula test. The duplication and per-module resolution cleanups are nice-to-have. Please also confirm the uncached-export semantics match Next.js.
|
Review posted to PR #1871. SummaryThis revision fixes both blockers from the prior review (normalised key + manifest registration), and I confirmed the core mechanics now line up against the Blocking-ish (correctness): The build-mode key uses a fragile string
A mismatch yields Blocking (process): The added test ( Notes: the resolved server reference is the uncached hoisted export (caching applies at render, not on direct action invoke) — needs confirmation against Next.js. Plus minor cleanups: heavy duplication of the Verified locally: lint and format pass; the PR unit test passes (but is self-referential). |
…forward-reference module-level code
The previous approach used `noExport: true` and appended module-level
`const ${name}_$$vcf` declarations at the end of the transformed file,
then referenced them via forward reference at the call-site. This caused
a temporal dead zone (TDZ) error because `const` bindings are not
hoisted — the call-site assignment evaluated before the TLA const was
initialized, crashing all RSC files that contain function-level "use
cache" (HTTP 500 for use-cache pages, route handlers, etc.).
Fix: keep the existing hoisting/export behaviour (`noExport` stays
false) and instead wrap `registerCachedFunction(...)` with
`registerServerReference(...)` inline at call-site in the RSC
environment. This adds the RSC serialisation metadata ($$typeof, $$id)
so cached functions can be passed as props to client components
(useActionState / formAction), while not disturbing the existing
exported binding that loadServerAction relies on.
…r nested function props
The previous approach passed the raw absolute file path as the $$id to
registerServerReference. @vitejs/plugin-rsc resolves server references by a
normalised key (sha256(toRelativeId) in build; URL-path in dev), so production
would throw "server reference not found" for any cached function passed as a
client-component prop.
Also, the module was never added to the virtual:vite-rsc/server-references
manifest because only the plugin's own "use server" transform writes to
manager.serverReferenceMetaMap. Without a manifest entry, the production
serverReferences lookup has no entry for the module at all.
Fix:
- Capture the plugin-rsc manager via the rsc:minimal plugin API in
configResolved so we can write to serverReferenceMetaMap directly.
- Compute normalizedRefKey to match vitePluginUseServer's getNormalizedId():
build → sha256(toRelativeId(id)).hex.slice(0,12)
dev → id.slice(root.length) (Vite URL path)
- After transformHoistInlineDirective succeeds, register the hoisted export
names in manager.serverReferenceMetaMap[id] so the manifest is populated.
- Pass normalizedRefKey (not raw id) to registerServerReference.
Add unit tests verifying the hash formula matches plugin-rsc's own logic.
…register manifest after rsc:use-server - Derive the build-mode reference key via plugin-rsc's own manager.toRelativeId() instead of a string slice, so the hash input is byte-for-byte identical to the plugin's hashString(toRelativeId(id)). - Reassign each hoisted inline 'use cache' export at module level to registerServerReference(registerCachedFunction(fn)) so the module export itself is the cached wrapper (Next.js parity: direct action invocation goes through the cache) and call sites/manifest imports all observe the same wrapped function. - Register serverReferenceMetaMap entries from a new vinext:use-cache-server-references plugin placed after the plugin-rsc plugins: rsc:use-server deletes metaMap entries for modules without 'use server', which wiped the entries written during the use-cache transform (prod actions 404'd with 'server reference not found'). - Deduplicate the RSC/non-RSC transform branches into a single transformHoistInlineDirective call and hoist the @vitejs/plugin-rsc/react/rsc resolution out of the per-module path. - Replace the self-referential key-formula unit test with the ported Next.js fixture (use-cache-with-server-function-props/nested-cache), a dev-mode Playwright round-trip test, and a production-server integration test that resolves the serialized references via action POSTs and asserts cached-invoke semantics.
741d366 to
1d3c833
Compare
|
Review addressed (commit 1d3c833): Build-mode key formula (blocking-ish): The build key is now derived via plugin-rsc's own Real round-trip test (blocking, process): Replaced
Uncached-export semantics (note): Confirmed against Next.js — its use-cache transform exports the cache wrapper itself ( Found while adding the prod test — a real bug in the previous revision: plugin-rsc's Minor items:
Known limitation (documented in code): closure-captured bind args of inline cached fns passed as props are serialized unencrypted (plugin-rsc encrypts |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
I re-traced the @vitejs/plugin-rsc@0.5.27 runtime against this revision and verified the previously-blocking items are now genuinely fixed and exercised end-to-end. Both the build manifest path and the dev import(id) path are now covered by passing round-trip tests (I ran them locally).
Verified working
- Build round-trip (prod manifest):
tests/app-router-production-server.test.ts→ "resolves nested 'use cache' functions passed as props when invoked as actions" passes locally. It extracts the serialized reference ids from the flight payload and POSTs them as actions against the built server, asserting they resolve (not a 404). This is exactly the prod-only path the first review flagged as broken, and it now passes. It also asserts cached-invoke semantics (two identical POSTs return the same date), confirming the exported binding is the cached wrapper. - Dev round-trip: the new Playwright test in
tests/e2e/app-router/use-cache.spec.tsruns againstvp dev(projectapp-router) and clicks bothuseActionStateform buttons. Passed locally (8 passed, including the 7 pre-existing use-cache specs — no regression). - Key formula parity: the build key now uses
hashString(manager.toRelativeId(id))via the capturedrscPluginApi.manager, byte-for-byte matching plugin-rsc'sgetNormalizedId()(plugin-BK29Va7z.js:1587). The fragile string-slice is gone. Thepath.relativefallback only applies when the manager is unavailable (in which case the manifest can't be populated anyway). - metaMap deletion ordering: confirmed plugin-rsc's
rsc:use-servertransform deletesserverReferenceMetaMap[id]for any module lacking"use server"(plugin-BK29Va7z.js:1603/1623). The PR correctly defers registration to a separatevinext:use-cache-server-referencesplugin pushed after the rsc plugins, so the entry survives. The consume-and-delete (useCacheServerRefMeta) prevents stale re-registration on HMR. - Reassignment validity:
transformHoistInlineDirectiveemitsexport async function $$hoist_N(...)declarations (transforms/index.js:27), which are reassignable function bindings, so the prepended$$hoist_N = registerServerReference(registerCachedFunction(...))is valid. Hoisted names are always$$hoist_N, neverdefault, so there's nodefault = ...hazard. - Branch is now rebased onto current
main(merge-base = latest main). Process gaps from prior reviews (no test, behind main, self-referential test) are resolved — the self-referentialuse-cache-server-ref-key.test.tswas replaced with real round-trip tests.
vp check packages/vinext/src/index.ts passes (format + lint + types). tests/features.test.ts (311) passes.
Non-blocking
-
Dev key omits
cleanUrl//@fs/handling for node_modules. plugin-rsc's devgetNormalizedId()doesid = cleanUrl(id)whenid.includes("/node_modules/")(plugin-BK29Va7z.js:1583-1585) and falls back to a/@fs/-prefixed URL for ids outside the project root (normalizeResolvedIdToUrl,plugin-BK29Va7z.js:82-85). The PR's dev branch (index.ts:4360-4365) does a plainid.slice(projectRoot.length)and returns the rawidotherwise — no query stripping, no/@fs/. For under-root app/page modules (the real use case, and what the fixture exercises) the two agree, which is why the dev test passes. But an inline"use cache"fn defined inside anode_modulespackage, or a module resolved with a query, would produce a divergent key and fail to resolve on submit. This is an edge case worth a short comment (or reusingcleanUrl) rather than a blocker. -
Encrypted bound args limitation is real and documented. Closure-captured bind args of inline cached fns passed as props are serialized unencrypted, unlike plugin-rsc's
"use server"transform. The code comment (index.ts:4325-4331) acknowledges this and ties it to cache-key determinism; the ported fixture has no closures so it's untested. Fine as a documented follow-up, but the gap means a cached fn prop that captures a secret would leak it to the client — worth tracking in an issue so it isn't forgotten.
Summary
The approach is sound and, crucially, now proven by both a dev and a prod round-trip test rather than just serialization. The two original blockers (wrong reference id; module not in manifest) are resolved, the key derivation reuses the plugin's authoritative toRelativeId, and the metaMap-deletion ordering bug discovered while adding the prod test is handled. Remaining items are non-blocking edge cases (node_modules/query dev keys, unencrypted bound args). I'd suggest filing an issue for the bound-args limitation before/after merge.
|
Review posted to PR #1871. SummaryI re-traced the Verified working:
Non-blocking notes:
Bottom line: both original blockers are resolved and now proven by dev + prod tests. Remaining items are non-blocking edge cases. |
…e server references
|
Review addressed (commit e8a434b — comment-only): Dev key normalisation (non-blocking #1): Documented the scope directly at the dev-key derivation. Two of the divergent shapes are structurally unreachable rather than latent bugs: the transform's filter excludes Unencrypted bound args (non-blocking #2): Tracked as a follow-up work item with full context (design constraint: cache keys are computed from the wrapper's arguments, so AES-GCM ciphertext per-call nondeterminism must not leak into the key — likely needs decrypt-inside-the-hoisted-fn before keying, mirroring Next.js' |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
I re-traced @vitejs/plugin-rsc@0.5.27 against the current revision (e8a434b) and re-ran both round-trip tests locally. The two original blockers (wrong reference id; module not in manifest) remain resolved, and I confirmed the core mechanics against the plugin source rather than trusting the prior reviews.
Verified working
- Prod build round-trip —
tests/app-router-production-server.test.ts→ "resolves nested 'use cache' functions passed as props when invoked as actions" passes locally (extracts the<12-hex>#$$hoist_N_namereference ids from the flight payload, POSTs them as actions against the built server, asserts they resolve and that two identical POSTs return the same cached date). This is the prod-only manifest path the first review flagged as broken. - Dev round-trip — the new Playwright test (
app-routerproject) passes; clicking bothuseActionStatebuttons renders the ISO date / random. - Build key parity —
hashString(manager.toRelativeId(id))is byte-for-byte identical to the plugin'sgetNormalizedId()(plugin-BK29Va7z.js:1587;hashString= sha256→hex→slice(0,12),plugin-BK29Va7z.js:244). - metaMap deletion ordering — confirmed
rsc:use-serverdeletesserverReferenceMetaMap[id]for any module lacking"use server"(plugin-BK29Va7z.js:1564).rsc:use-serveris a normal (un-enforced) plugin;vinext:use-cache-server-referencesis pushed after the rsc plugin promise, so it transforms after and re-registers. The consume-and-delete onuseCacheServerRefMetaprevents stale re-registration on HMR. - Reassignment validity —
transformHoistInlineDirectiveemitsexport async function $$hoist_N_<name>(...)declarations (transforms/index.js:27) and moves the declaration to end-of-file (:29). The prepended$$hoist_N = registerServerReference(registerCachedFunction(...))still works because function declarations hoist regardless of source position (verified with a standalone repro).registerCachedFunctionreturns a new closure (it doesn't mutatefn), so the reassignment is necessary for call sites to observe the wrapped fn. Hoisted names are neverdefault, so nodefault = …hazard. - Cache-id consistency — both the RSC and non-RSC branches key the cache as
id + ":" + name(preserved from pre-PR), so SSR/RSC cache keys still agree.
vp check packages/vinext/src/index.ts passes (format + lint + types).
Non-blocking
-
Closure-captured bound args are unencrypted (real divergence, untested). The "Known limitation" comment (
index.ts:4325-4331) is accurate: bound args hoisted into.bind(null, …)are serialized to the client unencrypted, unlike plugin-rsc's"use server"transform which encrypts by default (plugin-BK29Va7z.js:1598). This means a cached fn prop that closes over a secret leaks it to the client — a genuine data-exposure divergence from Next.js. The ported fixture has no closures, so this path has zero test coverage. Please file a tracking issue (and ideally add a fixture exercising a closure so the behavior is at least pinned, even if just asserting the documented limitation) before this expands in scope. -
Manager-less build fallback produces a latent broken state, not a loud failure. When
rscPluginApiis null in a build, the key falls back topath.relative(...)and the code still prepends theregisterServerReferencecall with that fallback key — butvinext:use-cache-server-referencesbails (!rscPluginApi?.manager), so no manifest entry is written. The result is a serializable-but-unresolvable reference → prod 404 on submit. This shouldn't occur when RSC is active (rsc:minimalis always present), but if it ever did, the failure mode is a runtime 404 rather than a build error. Consider skipping the wrapping entirely (or throwing) when the manager is unavailable, so the broken state can't ship silently. -
Dev key omits
cleanUrl//@fs/normalization — documented atindex.ts:4360-4374and the two divergent shapes are argued unreachable (the transform filter excludesnode_modules; the extension-anchored id regex rejects?queryids). I agree those are structurally unreachable for the app/page case the fixture covers. Fine as documented; the out-of-root absolute-path branch is self-consistent (same key registered + handed toimport(id)).
Summary
Both original blockers are resolved and now proven by dev + prod round-trip tests, and the mechanics check out against the plugin source. No blocking issues remain. The unencrypted-bound-args divergence (#1) is the most important follow-up — it's a real client-exposure gap with no test coverage and should get a tracking issue; the manager-less fallback (#2) would be safer failing loudly than shipping a silent 404.
|
Review posted to PR #1871. SummaryI re-traced Verified working (no blocking issues):
Non-blocking follow-ups I flagged:
Bottom line: implementation is sound and proven end-to-end; remaining items are non-blocking. |
…erver references when the plugin-rsc manager is missing When the @vitejs/plugin-rsc manager is unavailable in the rsc environment, the inline 'use cache' transform previously fell back to a locally computed reference key and still wrapped the hoisted exports — but the manifest registration plugin bails without the manager, so the emitted reference would serialize into the RSC payload yet never resolve (silent 404 on action POST in production). Fail loudly at transform time instead; the manager is a structural invariant whenever the rsc environment exists. Adds transform-level unit tests for the fail-loud path (build + dev), the non-rsc no-manager control, and build reference-key parity with plugin-rsc.
…ment the divergence Extends the nested-fn-props fixture with a cached function that closes over a value from the cached component's scope, exercising the .bind(null, ...) bound-arg path end to end: the production round-trip test asserts the captured value appears in plaintext in the flight payload (pinning the documented divergence from Next.js, which encrypts bound args by default) and that invoking the bound reference observes the captured value; the Playwright test covers the real flight-client encodeReply round-trip in dev. A transform-level test pins that captures are emitted as plain bind args. The divergence is now also documented in the README's Known limitations section.
|
Addressed both non-blocking follow-ups from the latest review (commits f600230, eeda6cf): 1. Unencrypted closure-captured bound args — coverage + documentation (eeda6cf) Full encryption parity is out of scope here (as noted in the code, encrypting bound args interacts with cache-key determinism — AES-GCM ciphertext differs per render — and needs its own design pass), so the divergence is now pinned and prominently documented instead of silently untested:
2. Manager-less build fallback → fail loud (f600230) The transform now throws when the rsc environment is active but the plugin-rsc manager is unavailable, instead of computing a fallback key and emitting a serializable-but-unresolvable reference. The throw is placed before the hoist 3. Dev key cleanUrl//@fs/ normalization — no change, per the review's agreement that the divergent shapes are structurally unreachable (documented at the dev-key computation site). Verified: |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
I re-traced @vitejs/plugin-rsc@0.5.27 against the current revision (eeda6cf) and re-ran the unit, prod-server, and transform tests locally. Both original blockers (wrong reference id; module not in manifest) remain resolved, and the two non-blocking follow-ups from the prior review are addressed. I have no blocking issues.
Verified against the plugin source
- Build key parity.
hashString(manager.toRelativeId(id))is byte-for-byte identical to plugin-rsc'sgetNormalizedId()build branch (plugin-BK29Va7z.js:1587;hashString=sha256→hex→slice(0,12),:244). The fragile string-slice from earlier revisions is gone. - Dev key parity (under-root case). plugin-rsc's dev key for an under-root id is
resolved.id.slice(root.length)(normalizeResolvedIdToUrl,:82), which keeps the leading/(e.g./app/.../page.tsx). The PR'sid.slice(projectRoot.length)(index.ts:4392-4395) matches exactly. Thenode_modules/?query//@fs/shapes the plugin also handles are correctly argued unreachable (the transform filter excludesnode_modules; the extension-anchored id regex rejects?queryids) and documented at the derivation site. - metaMap deletion ordering.
rsc:use-serverdeletesserverReferenceMetaMap[id]for any module lacking"use server"(:1564,:1603,:1623). The PR correctly defers registration tovinext:use-cache-server-references, pushed after the rsc plugin promise, so itstransformruns afterrsc:use-serverand re-populates the entry. The consume-and-delete onuseCacheServerRefMetaprevents stale re-registration on HMR. - Dev validation path.
rsc:reference-validation(:616) checksserverReferenceMetaMapbyreferenceKey, and the dev loader doesimport(id)directly (rsc.js:19-21). The registered key is both a valid metaMap entry and a Vite-importable id, so both validation and import resolve. - Reassignment validity.
transformHoistInlineDirectiveemitsexport async function $$hoist_N(...)declarations (transforms/index.js:27) — reassignable, fully-hoisted live bindings, neverdefault. The prepended$$hoist_N = registerServerReference(registerCachedFunction($$hoist_N, …))runs at module-body start when the declaration already exists, andregisterCachedFunctionreturns a new closure (cache-runtime.ts:457), so the reassignment is both necessary and correct. - Fail-loud placement. The missing-manager
throw(index.ts:4355) sits outside the hoisttry/catch(:4404, swallow at:4462), so it can't be silently eaten — addressing follow-up #2 from the last review. Unit tests cover the build + dev throw paths and a non-RSC control (use-cache-transform.test.ts).
Tests run locally (all pass)
tests/use-cache-transform.test.ts— 5 passed. These now exercise vinext's actual transform hook (manager-less throw, build-key parity, plaintext bound-arg pinning) rather than a self-referential formula.tests/app-router-production-server.test.ts -t nested— passes; extracts the<12-hex>#$$hoist_N_namereference ids from the flight payload, POSTs them as actions against the built server, asserts they resolve (not 404) and that two identicalgetDatePOSTs return the same cached value.
Non-blocking follow-ups (both adequately addressed)
- Unencrypted closure-captured bound args — the real divergence from Next.js (which encrypts bound args by default) is now (a) pinned at the transform level (
.bind(null, capturedSecret), no encrypt wrapper), (b) pinned at runtime in the prod test (captured value appears verbatim/plaintext in the flight payload), (c) exercised end-to-end in the Playwright test via the newgetMessageprop, and (d) documented in the README "Known limitations" with a concrete mitigation ("pass an identifier and re-read the secret on the server"). This is the right outcome given encryption interacts with cache-key determinism and needs its own design pass. - Manager-less build fallback — now throws loudly instead of emitting a serializable-but-unresolvable reference.
Minor (optional, non-blocking)
getRscReactRscUrl()vs the cache-runtime bare import. The prepend importsregisterServerReferencefrom afile://URL whilecache-runtime.ts:215imports@vitejs/plugin-rsc/react/rscas a bare specifier. The comment asserts Vite normalises these to one module id in the RSC env; the passing round-trip tests confirm it holds today, but it's a latent coupling worth a brief note if plugin-rsc ever changes its module-id normalisation.getMessagecross-path cache coverage. The prod test asserts cached-invoke semantics forgetDate(two identical POSTs return the same value) but not that the closure-boundgetMessageproduces the same key on render vs direct action POST. Not a correctness concern given how the bound arg flows, but an extra assertion would fully pin the bound-arg cache key.
Summary
The approach is sound and proven end-to-end by transform-level, prod-build, and dev round-trip tests. Both original blockers are resolved with byte-for-byte key parity against the plugin, the metaMap-deletion ordering is correctly handled, the manager-less path fails loudly, and the unencrypted-bound-args divergence is pinned + documented rather than silently untested. vp check passes. No blocking issues; the remaining items are optional polish.
|
Review posted to PR #1871. SummaryI re-traced Verified against the plugin source:
Tests run locally (all pass): transform unit tests (now exercising the real transform), prod-server nested-fn-props round-trip (extracts reference ids → action POST → resolves + cached semantics), and Non-blocking items, both adequately addressed: the unencrypted closure-captured bound-args divergence is now pinned at transform + runtime levels and documented in the README; the manager-less fallback now throws. I flagged two optional polish items (the |
…him to decouple from plugin-rsc module-id normalisation The inline 'use cache' prepend imported registerServerReference from a file:// URL of @vitejs/plugin-rsc/react/rsc while the cache runtime imports the same package via the bare specifier, relying on Vite normalising both to a single module id. Re-export it instead from a new vinext-owned cache-server-reference shim whose only react/rsc specifier is the same bare one cache-runtime uses, resolved from the same importer location — one module instance by construction. The transform unit test now pins that the emitted import targets the shim and never a plugin-rsc file URL.
…tMessage path Mirror the getDate cache assertion on the closure-bound path: the fixture's getMessage now appends a Math.random() suffix so cache hits are observable, and the production-server round-trip asserts that two identical bound-arg invocations return the same cached value while a different bound arg misses instead of reusing the entry. The Playwright assertion matches the suffixed message via regex.
|
Both optional polish items from the latest review are now addressed in-PR ( 1. 2. Cross-path cache assertion for the closure-bound Verification: |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(use-cache): support nested cache functions passed as props
I traced @vitejs/plugin-rsc@0.5.27 against the current revision (67d7fb2) and re-ran the unit, transform, prod-server, and dev (Playwright) round-trip tests locally. The two original blockers remain resolved, all prior follow-ups are addressed, and I found no blocking issues. I verified the mechanics against the plugin source rather than relying on the prior reviews.
Verified against the plugin source
- Build key parity. The PR's build key is
createHash("sha256").update(manager.toRelativeId(id)).digest().toString("hex").slice(0,12)(index.ts:4357-4363). plugin-rsc'shashStringiscreateHash("sha256").update(v).digest().toString("hex").slice(0,12)(plugin-BK29Va7z.js:244-246) applied tomanager.toRelativeId(id)(:1587). Byte-for-byte identical, and it reuses the plugin's owntoRelativeIdso the input can't drift. - Dev key parity (under-root case). plugin-rsc's dev key for an under-root id is
resolved.id.slice(root.length)(normalizeResolvedIdToUrl,:82), keeping the leading/. The PR'sid.slice(projectRoot.length)for under-root ids matches (:4379-4382). Thenode_modules/?query//@fs/shapes plugin-rsc additionally handles are correctly argued structurally unreachable (the transform filter excludesnode_modules; the extension-anchored id regex rejects?queryids) and documented at the derivation site. - metaMap-deletion ordering. Confirmed
rsc:use-serverdeletesserverReferenceMetaMap[id]for any module lacking"use server"(:1563-1565,:1603,:1623). The PR correctly defers registration to a separatevinext:use-cache-server-referencesplugin pushed after the rsc plugin promise, with consume-and-delete onuseCacheServerRefMetato avoid stale HMR re-registration. - Manifest shape.
virtual:vite-rsc/server-referencesis keyed bymeta.referenceKey, importsmeta.importId, and destructuresmeta.exportNames(:1654-1665). The PR writes{ importId: id, referenceKey: normalizedRefKey, exportNames: result.names }, and the$$hoist_N_nameexports are valid identifiers the manifest can re-import. rsc:minimalapi lookup. TheconfigResolvedlookup (config.plugins.find(p => p.name === "rsc:minimal")?.api) mirrorsgetPluginApiexactly (:568-569); themanagerobject reference exists at plugin construction (:577), andmanager.config.root(read only at transform time) is populated byrsc:minimal's ownconfigResolved(:581-582) — no hook-ordering hazard.- Reassignment validity.
transformHoistInlineDirectiveemitsexport async function $$hoist_N(...)declarations and moves them to end-of-file (transforms/index.js:27-29); inline arrows passed as props also become declarations (declNamefalsy → stillfunction $$hoist_N_anonymous_server_function). Hoisted declarations exist before the prepended reassignment runs, so every later reader observes the wrapped fn.registerCachedFunctionreturns a new closure, so the reassignment is necessary. Names are neverdefault. - Cache-key consistency. Both branches key the cache as
id + ":" + name(preserved frommain), so SSR/RSC cache keys still agree. - Fail-loud placement. The missing-manager
throw(:4342-4350) is outside the hoisttry/catch, so it can't be silently swallowed.
Tests run locally (all pass)
tests/use-cache-transform.test.ts— 5 passed (manager-less throw in build+dev, non-RSC control, build-key parity with a fake manager, plaintext bound-arg pinning). Crucially these now exercise vinext's actual transform hook, not a self-referential formula.tests/app-router-production-server.test.ts -t nested— 3 passed: extracts the<12-hex>#$$hoist_N_namereference ids from the flight payload, POSTs them as actions against the built server, asserts they resolve (not 404), cached-invoke semantics on both the unbound (getDate) and closure-bound (getMessage) paths, and that the bound arg participates in the cache key.tests/e2e/app-router/use-cache.spec.ts"nested cache functions as props" — passed againstvp dev(after installing the chromium binary). Covers the full dev client→serveruseActionStateround-trip including the bound-arg flight path.vp checkon the changed source files passes (format + lint + types).
Non-blocking notes (no change required)
- Unencrypted closure-captured bound args remains a real, documented divergence from Next.js (which encrypts bound args by default —
:1594-1599). It is now pinned at the transform level, in the prod test (plaintext-in-payload assertion), end-to-end in Playwright, and surfaced in the README "Known limitations". This is the right call given encryption interacts with cache-key determinism. The README mitigation ("pass an identifier and re-read the secret on the server") is sound. Worth a tracking issue so it isn't lost, but it does not block this PR. Object.defineProperty($$hoist_N, "name", ...)appended at end-of-file by the hoist transform runs after the prepended reassignment, so it setsnameon the wrapped closure rather than the original. Benign (cosmetic only), just noting it for completeness.
Summary
The approach is sound and proven end-to-end by transform-level, prod-build, and dev round-trip tests, with byte-for-byte key parity against the plugin and the metaMap-deletion ordering handled correctly. Both original blockers are resolved, the manager-less path fails loudly, and the unencrypted-bound-args divergence is pinned and documented rather than silently untested. No blocking issues.
|
Review posted to PR #1871. SummaryI traced Verified against the plugin source rather than trusting prior reviews:
Tests run locally (all pass): transform unit tests (now exercising the real transform), prod-build round-trip (extracts reference ids → action POST → resolves + cached semantics on both unbound and closure-bound paths), dev Playwright round-trip, and Non-blocking notes: the unencrypted closure-captured bound-args divergence is real but now pinned + documented (worth a tracking issue); and a benign cosmetic note about |
| - **Image optimization doesn't happen at build time.** Remote images work via `@unpic/react` (auto-detects 28 CDN providers). Local images are routed through a `/_next/image` endpoint that can resize and transcode on Cloudflare Workers (via the Images binding) in production, but no build-time optimization or static resizing occurs. | ||
| - **Google Fonts are loaded from the CDN, not self-hosted.** No `size-adjust` fallback font metrics. Local fonts work but `@font-face` CSS is injected at runtime, not extracted at build time. | ||
| - **Route segment config** — `runtime` and `preferredRegion` are ignored (everything runs in the same environment). | ||
| - **Closure-captured arguments of inline `"use cache"` functions are not encrypted.** When an inline `"use cache"` function that closes over server-scope variables is passed to a client component (e.g. as a `formAction` / `useActionState` prop), the captured values are serialized into the RSC payload as plain, unencrypted bound arguments — Next.js encrypts these by default. Until this gap is closed, don't close over secrets in cached functions you pass to the client; pass an identifier and re-read the secret on the server instead. (Encrypting bound args interacts with cache-key determinism — ciphertext differs per render — and needs its own design pass.) |
There was a problem hiding this comment.
would it help at all if we exported some extra stuff from that library? there's a potential to make more of their functionality available for us to use if needed. let me know.
# Conflicts: # packages/vinext/src/index.ts # pnpm-lock.yaml
Failing test:
test/e2e/app-dir/use-cache-with-server-function-props— "should be able to use nested cache functions as props".Change in
packages/vinext/src/index.ts.Recovered from a cut-off worker; CI + bonk to verify. (No unit test added by the worker — may add per bonk.)