diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 01cadf3b0..8bc3470d9 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -1781,6 +1781,26 @@ function defineTest(f: Fixture) { ) }) + test('use cache replays Flight with framework server action', async ({ + page, + }) => { + await page.goto(f.url()) + await waitForHydration(page) + const locator = page.getByTestId( + 'test-use-cache-flight-replay-server-action', + ) + await expect( + locator.getByTestId('test-use-cache-flight-replay-server-action-cache'), + ).toHaveText('cached product card render count: 1') + await locator.getByRole('button', { name: 'add cached product' }).click() + await expect( + locator.getByTestId('test-use-cache-flight-replay-server-action-result'), + ).toHaveText(/^added rsc-product-1 with framework action \([1-9]\d*\)$/) + await expect( + locator.getByTestId('test-use-cache-flight-replay-server-action-cache'), + ).toHaveText('cached product card render count: 1') + }) + test('hydration mismatch', async ({ page }) => { const errors: Error[] = [] page.on('pageerror', (error) => { diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx index e26f0b674..c75bc93a5 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx @@ -9,6 +9,7 @@ import { import type React from 'react' import type { ReactFormState } from 'react-dom/client' import { parseRenderRequest } from './request.tsx' +import { loadFrameworkServerReference } from './server-reference-runtime.ts' // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -50,7 +51,9 @@ async function handleRequest({ : await request.text() temporaryReferences = createTemporaryReferenceSet() const args = await decodeReply(body, { temporaryReferences }) - const action = await loadServerAction(renderRequest.actionId) + const action = + loadFrameworkServerReference(renderRequest.actionId) ?? + (await loadServerAction(renderRequest.actionId)) try { const data = await action.apply(null, args) returnValue = { ok: true, data } diff --git a/packages/plugin-rsc/examples/basic/src/framework/server-reference-runtime.ts b/packages/plugin-rsc/examples/basic/src/framework/server-reference-runtime.ts new file mode 100644 index 000000000..dc2c94a96 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/server-reference-runtime.ts @@ -0,0 +1,14 @@ +import { registerServerReference } from '@vitejs/plugin-rsc/rsc' + +const frameworkServerReferences = new Map() + +export function registerFrameworkServerReference< + T extends (...args: any[]) => unknown, +>(reference: T, id: string, name: string): T { + frameworkServerReferences.set(`${id}#${name}`, reference) + return registerServerReference(reference, id, name) as T +} + +export function loadFrameworkServerReference(id: string): Function | undefined { + return frameworkServerReferences.get(id) +} diff --git a/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx index a25e56c6c..9f43b1be4 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx @@ -65,6 +65,9 @@ export default function cacheWrapper(fn: (...args: any[]) => Promise) { const result = createFromReadableStream(stream, { environmentName: 'Cache', replayConsoleLogs: true, + // Cached RSC streams can contain framework-owned server references whose + // implementation modules are not resolvable by the app bundler runtime. + serverReferences: 'preserve', temporaryReferences: clientTemporaryReferences, }) return result diff --git a/packages/plugin-rsc/examples/basic/src/routes/use-cache/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/use-cache/client.tsx new file mode 100644 index 000000000..0b6c00236 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/use-cache/client.tsx @@ -0,0 +1,29 @@ +'use client' + +import React from 'react' + +export function TestUseCacheFlightReplayServerActionClient(props: { + addToCart: (productId: string) => Promise + productId: string + renderCount: number +}) { + const [result, setResult] = React.useState('idle') + + return ( +
+ + cached product card render count: {props.renderCount} + + + + {result} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx index 9c5e09f4c..d09808edf 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx @@ -1,4 +1,6 @@ +import { registerFrameworkServerReference } from '../../framework/server-reference-runtime' import { revalidateCache } from '../../framework/use-cache-runtime' +import { TestUseCacheFlightReplayServerActionClient } from './client' export function TestUseCache() { return ( @@ -6,6 +8,7 @@ export function TestUseCache() { + ) } @@ -103,3 +106,32 @@ let outerFnArg = '' let innerFnArg = '' let innerFnCount = 0 let actionCount2 = 0 + +async function TestUseCacheFlightReplayServerAction() { + return +} + +async function CachedProductCard(props: { productId: string }) { + 'use cache' + cachedProductCardRenderCount++ + return ( + + ) +} + +let cachedProductCardRenderCount = 0 +let cartActionCount = 0 + +const addCachedProductToCart = registerFrameworkServerReference( + async (productId: string) => { + cartActionCount++ + return `added ${productId} with framework action (${cartActionCount})` + }, + 'framework:cached-flight-product-card', + 'addToCart', +) diff --git a/packages/plugin-rsc/src/core/rsc.test.ts b/packages/plugin-rsc/src/core/rsc.test.ts new file mode 100644 index 000000000..8f2eb55a7 --- /dev/null +++ b/packages/plugin-rsc/src/core/rsc.test.ts @@ -0,0 +1,37 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@vitejs/plugin-rsc/vendor/react-server-dom/server.edge', () => ({ + registerClientReference: vi.fn(), + registerServerReference(reference: Function, id: string, name: string) { + return Object.defineProperties(reference, { + $$typeof: { value: Symbol.for('react.server.reference') }, + $$id: { value: `${id}#${name}` }, + $$bound: { value: null, writable: true }, + }) + }, +})) + +const { createServerManifest, setRequireModule } = await import('./rsc') + +beforeAll(() => { + setRequireModule({ + load() { + throw new Error('preserved references must not load their implementation') + }, + }) +}) + +describe('createServerManifest', () => { + it('preserves server references without loading their implementation', async () => { + const manifest = createServerManifest({ preserveServerReferences: true }) + const entry = manifest['module-id#action']! + expect(entry.id).toContain('$$decode-server-reference:module-id') + + const module = await (globalThis as any).__vite_rsc_require__(entry.id) + expect(Object.prototype.hasOwnProperty.call(module, 'action')).toBe(true) + const reference = module.action + expect(reference.$$typeof).toBe(Symbol.for('react.server.reference')) + expect(reference.$$id).toBe('module-id#action') + expect(reference.$$bound).toBeNull() + }) +}) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index ecd46ea47..ad74b3a66 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -2,6 +2,7 @@ import { memoize, tinyassert } from '@hiogawa/utils' import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' import { SERVER_DECODE_CLIENT_PREFIX, + SERVER_DECODE_REFERENCE_PREFIX, SERVER_REFERENCE_PREFIX, createReferenceCacheTag, removeReferenceCacheTag, @@ -27,6 +28,28 @@ export function setRequireModule(options: { // need memoize to return stable promise from __webpack_require__ ;(globalThis as any).__vite_rsc_server_require__ = memoize( async (id: string) => { + if (id.startsWith(SERVER_DECODE_REFERENCE_PREFIX)) { + id = id.slice(SERVER_DECODE_REFERENCE_PREFIX.length) + id = removeReferenceCacheTag(id) + const target = {} as Record + return new Proxy(target, { + getOwnPropertyDescriptor(_target, name) { + if (typeof name !== 'string' || name === 'then') { + return Reflect.getOwnPropertyDescriptor(target, name) + } + target[name] ??= ReactServer.registerServerReference( + () => { + throw new Error( + `Unexpectedly preserved server reference '${id}#${name}' is called on server`, + ) + }, + id, + name, + ) + return Reflect.getOwnPropertyDescriptor(target, name) + }, + }) + } if (id.startsWith(SERVER_DECODE_CLIENT_PREFIX)) { // decode client reference on the server id = id.slice(SERVER_DECODE_CLIENT_PREFIX.length) @@ -71,7 +94,9 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(): BundlerConfig { +export function createServerManifest(options?: { + preserveServerReferences?: boolean +}): BundlerConfig { const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' return new Proxy( @@ -83,7 +108,13 @@ export function createServerManifest(): BundlerConfig { tinyassert(id) tinyassert(name) return { - id: SERVER_REFERENCE_PREFIX + id + cacheTag, + id: + SERVER_REFERENCE_PREFIX + + (options?.preserveServerReferences + ? SERVER_DECODE_REFERENCE_PREFIX + : '') + + id + + cacheTag, name, chunks: [], async: true, diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts index bd3d18af3..937455395 100644 --- a/packages/plugin-rsc/src/core/shared.ts +++ b/packages/plugin-rsc/src/core/shared.ts @@ -3,6 +3,8 @@ export const SERVER_REFERENCE_PREFIX = '$$server:' export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' +export const SERVER_DECODE_REFERENCE_PREFIX = '$$decode-server-reference:' + // cache bust memoized require promise during dev export function createReferenceCacheTag(): string { const cache = Math.random().toString(36).slice(2) diff --git a/packages/plugin-rsc/src/plugin.test.ts b/packages/plugin-rsc/src/plugin.test.ts new file mode 100644 index 000000000..3b8280d6c --- /dev/null +++ b/packages/plugin-rsc/src/plugin.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { vitePluginRscMinimal } from './plugin' + +describe('server reference manifest', () => { + it('preserves and deduplicates existing export names', async () => { + const plugins = vitePluginRscMinimal({ enableActionEncryption: false }) + const minimalPlugin = plugins.find( + (plugin) => plugin.name === 'rsc:minimal', + )! + const manager = (minimalPlugin.api as any).manager + manager.config = { + command: 'build', + root: '/root', + } + + const id = '/root/actions.ts' + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: 'existing', + exportNames: ['cached', 'action', 'cached'], + } + + const useServerPlugin = plugins.find( + (plugin) => plugin.name === 'rsc:use-server', + )! + const transformHandler = (useServerPlugin.transform as any).handler + await transformHandler.call( + { + environment: { name: 'rsc', mode: 'build' }, + error(error: unknown) { + throw error + }, + }, + `"use server"; export async function action() {}`, + id, + ) + + expect(manager.serverReferenceMetaMap[id].exportNames).toEqual([ + 'cached', + 'action', + ]) + + const manifestPlugin = plugins.find( + (plugin) => plugin.name === 'rsc:virtual-vite-rsc/server-references', + )! + const loadHandler = (manifestPlugin.load as any).handler + const manifest = await loadHandler.call( + { environment: { mode: 'build' } }, + '\0virtual:vite-rsc/server-references', + {}, + ) + + expect(manifest.code.match(/\bcached\b/g)).toHaveLength(2) + expect(manifest.code.match(/\baction\b/g)).toHaveLength(2) + }) + + it('preserves metadata for configured server reference markers', async () => { + const marker = '/* framework-server-reference */' + const plugins = vitePluginRscMinimal({ + enableActionEncryption: false, + serverReferenceMarkers: [marker, ''], + }) + const minimalPlugin = plugins.find( + (plugin) => plugin.name === 'rsc:minimal', + )! + const manager = (minimalPlugin.api as any).manager + const useServerPlugin = plugins.find( + (plugin) => plugin.name === 'rsc:use-server', + )! + const transformHandler = (useServerPlugin.transform as any).handler + const transformContext = { + environment: { name: 'rsc', mode: 'build' }, + error(error: unknown) { + throw error + }, + } + + const markedId = '/root/marked.ts' + manager.serverReferenceMetaMap[markedId] = { + importId: markedId, + referenceKey: 'marked', + exportNames: ['cached'], + } + await transformHandler.call( + transformContext, + `${marker}\nexport {}`, + markedId, + ) + expect(manager.serverReferenceMetaMap[markedId]).toBeDefined() + + const unmarkedId = '/root/unmarked.ts' + manager.serverReferenceMetaMap[unmarkedId] = { + importId: unmarkedId, + referenceKey: 'unmarked', + exportNames: ['stale'], + } + await transformHandler.call(transformContext, 'export {}', unmarkedId) + expect(manager.serverReferenceMetaMap[unmarkedId]).toBeUndefined() + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01ec7b083..9c6dc0bff 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -171,6 +171,13 @@ class RscPluginManager { } export type RscPluginOptions = { + /** + * Source markers emitted by preceding transforms that register server + * references in the plugin manager. Modules containing a marker keep their + * existing server-reference metadata when they do not contain `"use server"`. + * @experimental + */ + serverReferenceMarkers?: string[] /** * shorthand for configuring `environments.(name).build.rollupOptions.input.index` */ @@ -1926,7 +1933,7 @@ function vitePluginDefineEncryptionKey( function vitePluginUseServer( useServerPluginOptions: Pick< RscPluginOptions, - 'enableActionEncryption' | 'environment' + 'enableActionEncryption' | 'environment' | 'serverReferenceMarkers' >, manager: RscPluginManager, ): Plugin[] { @@ -1946,7 +1953,13 @@ function vitePluginUseServer( // filter: { code: 'use server' }, async handler(code, id) { if (!code.includes('use server')) { - delete manager.serverReferenceMetaMap[id] + const hasServerReferenceMarker = + useServerPluginOptions.serverReferenceMarkers?.some( + (marker) => marker.length > 0 && code.includes(marker), + ) ?? false + if (!hasServerReferenceMarker) { + delete manager.serverReferenceMetaMap[id] + } return } let ast = await parseAstAsync(code) @@ -2017,11 +2030,16 @@ function vitePluginUseServer( delete manager.serverReferenceMetaMap[id] return } + const existingExportNames = + manager.serverReferenceMetaMap[id]?.exportNames ?? [] + const exportNames = + 'names' in result ? result.names : result.exportNames manager.serverReferenceMetaMap[id] = { importId: id, referenceKey: getNormalizedId(), - exportNames: - 'names' in result ? result.names : result.exportNames, + exportNames: [ + ...new Set([...existingExportNames, ...exportNames]), + ], } const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) output.prepend( @@ -2094,7 +2112,7 @@ function vitePluginUseServer( for (const meta of Object.values(manager.serverReferenceMetaMap)) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) - const exports = meta.exportNames + const exports = [...new Set(meta.exportNames)] .map((name) => (name === 'default' ? 'default: _default' : name)) .sort() code += ` diff --git a/packages/plugin-rsc/src/react/rsc.ts b/packages/plugin-rsc/src/react/rsc.ts index 117fe15ec..33aaca289 100644 --- a/packages/plugin-rsc/src/react/rsc.ts +++ b/packages/plugin-rsc/src/react/rsc.ts @@ -40,16 +40,22 @@ export function renderToReadableStream( export function createFromReadableStream( stream: ReadableStream, - options: CreateFromReadableStreamEdgeOptions = {}, + options: CreateFromReadableStreamEdgeOptions & { + /** Preserve decoded server references so the value can be rendered again. */ + serverReferences?: 'resolve' | 'preserve' + } = {}, ): Promise { + const { serverReferences = 'resolve', ...reactOptions } = options return ReactClient.createFromReadableStream(stream, { serverConsumerManifest: { // https://github.com/facebook/react/pull/31300 // https://github.com/vercel/next.js/pull/71527 - serverModuleMap: createServerManifest(), + serverModuleMap: createServerManifest({ + preserveServerReferences: serverReferences === 'preserve', + }), moduleMap: createServerDecodeClientManifest(), }, - ...options, + ...reactOptions, }) }