From 124f6ea1ff7f4b16425b639ac271fd72cb6f5057 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:06:49 +0900 Subject: [PATCH 01/24] refactor(rsc): track server reference claims Co-authored-by: OpenCode --- .../examples/browser-mode/vite.config.ts | 2 +- packages/plugin-rsc/src/plugin.ts | 248 +++++++++++++++--- 2 files changed, 210 insertions(+), 40 deletions(-) diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts index 6bd40a6a8..f6c611bdd 100644 --- a/packages/plugin-rsc/examples/browser-mode/vite.config.ts +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -216,7 +216,7 @@ function rscBrowserModePlugin(): Plugin[] { return `export default {}` // no-op during dev } let code = '' - for (const meta of Object.values(manager.serverReferenceMetaMap)) { + for (const meta of manager.getServerReferenceMeta()) { code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` } return `export default {${code}}` diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9253de54f..e961442f5 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -101,6 +101,17 @@ type ServerReferenceMeta = { exportNames: string[] } +type ServerReferenceClaimMap = Map< + string, + Map> +> + +const serverReferenceClaimMaps = new WeakMap() +const serverReferenceMetaMaps = new WeakMap< + object, + Record +>() + const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -129,7 +140,6 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} - serverReferenceMetaMap: Record = {} serverResourcesMetaMap: Record = {} environmentImportMetaMap: Record< string, // sourceEnv @@ -148,6 +158,140 @@ class RscPluginManager { this.serverResourcesMetaMap = sortObject(this.serverResourcesMetaMap) } + resolveServerReference( + id: string, + serverEnvironmentName: string, + ): ServerReferenceMeta { + const importId = + this.config.command !== 'build' && id.includes('/node_modules/') + ? cleanUrl(id) + : id + const referenceKey = + this.config.command === 'build' + ? hashString(this.toRelativeId(importId)) + : normalizeViteImportAnalysisUrl( + this.server.environments[serverEnvironmentName]!, + importId, + ) + return { importId, referenceKey, exportNames: [] } + } + + replaceServerReferenceClaim( + owner: string, + environmentName: string, + id: string, + meta: ServerReferenceMeta | undefined, + ): void { + const claimId = + this.config.command !== 'build' && id.includes('/node_modules/') + ? cleanUrl(id) + : id + let claimMap = serverReferenceClaimMaps.get(this) + let ownerMap = claimMap?.get(claimId) + let metaMap = serverReferenceMetaMaps.get(this) + if (!metaMap) { + metaMap = {} + serverReferenceMetaMaps.set(this, metaMap) + } + if (meta?.exportNames.length) { + if (!claimMap) { + claimMap = new Map() + serverReferenceClaimMaps.set(this, claimMap) + } + if (this.config.command !== 'build' && ownerMap) { + const identityChanged = [...ownerMap.values()].some((environmentMap) => + [...environmentMap.values()].some( + (claim) => + claim.importId === meta.importId && + claim.referenceKey !== meta.referenceKey, + ), + ) + if (identityChanged) { + claimMap.delete(claimId) + delete metaMap[claimId] + ownerMap = undefined + } + } + if (!ownerMap) { + ownerMap = new Map() + claimMap.set(claimId, ownerMap) + } + let environmentMap = ownerMap.get(owner) + if (!environmentMap) { + environmentMap = new Map() + ownerMap.set(owner, environmentMap) + } + environmentMap.set(environmentName, meta) + } else if (ownerMap) { + const environmentMap = ownerMap.get(owner) + environmentMap?.delete(environmentName) + if (environmentMap?.size === 0) ownerMap.delete(owner) + if (ownerMap.size === 0) claimMap?.delete(claimId) + } else { + return + } + + ownerMap = claimMap?.get(claimId) + if (!ownerMap) { + delete metaMap[claimId] + return + } + + let aggregate: ServerReferenceMeta | undefined + const exportOwners = new Map() + for (const [claimOwner, environmentMap] of ownerMap) { + for (const claim of environmentMap.values()) { + if (!aggregate) { + aggregate = { + importId: claim.importId, + referenceKey: claim.referenceKey, + exportNames: [], + } + } else if ( + aggregate.importId !== claim.importId || + aggregate.referenceKey !== claim.referenceKey + ) { + throw new Error( + `[vite-rsc] conflicting server reference identity for '${claimId}'`, + ) + } + for (const name of claim.exportNames) { + const existingOwner = exportOwners.get(name) + if (existingOwner && existingOwner !== claimOwner) { + throw new Error( + `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, + ) + } + exportOwners.set(name, claimOwner) + } + } + } + assert(aggregate) + aggregate.exportNames = [...exportOwners.keys()].sort() + metaMap[claimId] = aggregate + } + + getServerReferenceMeta(): ServerReferenceMeta[] { + return Object.values(serverReferenceMetaMaps.get(this) ?? {}) + } + + clearServerReferenceClaims(owner: string, id: string): void { + const claimId = + this.config.command !== 'build' && id.includes('/node_modules/') + ? cleanUrl(id) + : id + const environmentNames = [ + ...(serverReferenceClaimMaps + .get(this) + ?.get(claimId) + ?.get(owner) + ?.keys() ?? []), + ] + for (const environmentName of environmentNames) { + this.replaceServerReferenceClaim(owner, environmentName, id, undefined) + } + } + toRelativeId(id: string): string { return normalizePath(path.relative(this.config.root, id)) } @@ -362,9 +506,9 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - let meta = Object.values(manager.serverReferenceMetaMap).find( - (meta) => meta.referenceKey === parsed.id, - ) + let meta = manager + .getServerReferenceMeta() + .find((meta) => meta.referenceKey === parsed.id) if (!meta) { // Server references decoded by `createFromReadableStream` with // `preserveServerReferences` can reach action loading without their @@ -383,9 +527,9 @@ export function vitePluginRscMinimal( await this.environment.transformRequest(parsed.id) } } catch {} - meta = Object.values(manager.serverReferenceMetaMap).find( - (meta) => meta.referenceKey === parsed.id, - ) + meta = manager + .getServerReferenceMeta() + .find((meta) => meta.referenceKey === parsed.id) } if (meta) { return `export {}` @@ -1996,18 +2140,32 @@ function vitePluginUseServer( useServerPluginOptions.environment?.browser ?? 'client' const debug = createDebug('vite-rsc:use-server') + const referenceOwner = 'rsc:use-server' return [ { name: 'rsc:use-server', transform: { // TODO: cannot use filter because handler has cleanup side effect - // (`delete manager.serverReferenceMetaMap[id]`) that must run + // (removing the current environment's reference claim) that must run // even when directive is removed (HMR case) // filter: { code: 'use server' }, async handler(code, id) { + const clearReferenceClaim = () => + manager.replaceServerReferenceClaim( + referenceOwner, + this.environment.name, + id, + undefined, + ) + const clearAllReferenceClaims = () => + manager.clearServerReferenceClaims(referenceOwner, id) if (!code.includes('use server')) { - delete manager.serverReferenceMetaMap[id] + if (this.environment.name === serverEnvironmentName) { + clearAllReferenceClaims() + } else { + clearReferenceClaim() + } return } let ast = await parseAstAsync(code) @@ -2024,9 +2182,9 @@ function vitePluginUseServer( } } - let normalizedId_: string | undefined - const getNormalizedId = () => { - if (!normalizedId_) { + let serverReference_: ServerReferenceMeta | undefined + const getServerReference = () => { + if (!serverReference_) { if ( this.environment.mode === 'dev' && id.includes('/node_modules/') @@ -2037,18 +2195,13 @@ function vitePluginUseServer( debug( `internal server reference created through a package imported in ${this.environment.name} environment: ${id}`, ) - id = cleanUrl(id) - } - if (manager.config.command === 'build') { - normalizedId_ = hashString(manager.toRelativeId(id)) - } else { - normalizedId_ = normalizeViteImportAnalysisUrl( - manager.server.environments[serverEnvironmentName]!, - id, - ) } + serverReference_ = manager.resolveServerReference( + id, + serverEnvironmentName, + ) } - return normalizedId_ + return serverReference_ } if (this.environment.name === serverEnvironmentName) { @@ -2061,7 +2214,7 @@ function vitePluginUseServer( const result = transformServerActionServer_(code, ast, { runtime: (value, name) => `$$ReactServer.registerServerReference(${value}, ${JSON.stringify( - getNormalizedId(), + getServerReference().referenceKey, )}, ${JSON.stringify(name)})`, rejectNonAsyncFunction: true, encode: enableEncryption @@ -2075,15 +2228,22 @@ function vitePluginUseServer( }) const output = result.output if (!result || !output.hasChanged()) { - delete manager.serverReferenceMetaMap[id] + clearAllReferenceClaims() return } - manager.serverReferenceMetaMap[id] = { - importId: id, - referenceKey: getNormalizedId(), - exportNames: - 'names' in result ? result.names : result.exportNames, - } + // The RSC transform sees both module-level and inline directives, + // so its result supersedes stale discoveries from other environments. + clearAllReferenceClaims() + manager.replaceServerReferenceClaim( + referenceOwner, + this.environment.name, + id, + { + ...getServerReference(), + exportNames: + 'names' in result ? result.names : result.exportNames, + }, + ) const importSource = resolvePackage(`${PKG_NAME}/react/rsc/server`) output.prepend( `import * as $$ReactServer from "${importSource}";\n`, @@ -2102,7 +2262,7 @@ function vitePluginUseServer( } } else { if (!hasDirective(ast.body, 'use server')) { - delete manager.serverReferenceMetaMap[id] + clearReferenceClaim() return } const transformDirectiveProxyExport_ = withRollupError( @@ -2113,7 +2273,7 @@ function vitePluginUseServer( code, runtime: (name) => `$$ReactClient.createServerReference(` + - `${JSON.stringify(getNormalizedId() + '#' + name)},` + + `${JSON.stringify(getServerReference().referenceKey + '#' + name)},` + `$$ReactClient.callServer, ` + `undefined, ` + (this.environment.mode === 'dev' @@ -2123,14 +2283,24 @@ function vitePluginUseServer( directive: 'use server', rejectNonAsyncFunction: true, }) - if (!result) return + if (!result) { + clearReferenceClaim() + return + } const output = result?.output - if (!output?.hasChanged()) return - manager.serverReferenceMetaMap[id] = { - importId: id, - referenceKey: getNormalizedId(), - exportNames: result.exportNames, + if (!output?.hasChanged()) { + clearReferenceClaim() + return } + manager.replaceServerReferenceClaim( + referenceOwner, + this.environment.name, + id, + { + ...getServerReference(), + exportNames: result.exportNames, + }, + ) const name = this.environment.name === browserEnvironmentName ? 'browser' @@ -2152,7 +2322,7 @@ function vitePluginUseServer( return { code: `export {}`, map: null } } let code = '' - for (const meta of Object.values(manager.serverReferenceMetaMap)) { + for (const meta of manager.getServerReferenceMeta()) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) const exports = meta.exportNames From e459dd7a326744d8b2f6a0dbb8658e4a0d311f67 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:32:31 +0900 Subject: [PATCH 02/24] test(rsc): cover custom server function claims Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 35 ++++++++ .../custom-server-function/package.json | 24 ++++++ .../custom-server-function/src/actions.ts | 16 ++++ .../src/framework/entry.browser.tsx | 37 +++++++++ .../src/framework/entry.rsc.tsx | 60 ++++++++++++++ .../src/framework/entry.ssr.tsx | 21 +++++ .../src/framework/request.ts | 32 +++++++ .../custom-server-function/src/root.tsx | 22 +++++ .../custom-server-function/tsconfig.json | 17 ++++ .../custom-server-function/vite.config.ts | 83 +++++++++++++++++++ pnpm-lock.yaml | 28 +++++++ 11 files changed, 375 insertions(+) create mode 100644 packages/plugin-rsc/e2e/custom-server-function.test.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/package.json create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/actions.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/root.tsx create mode 100644 packages/plugin-rsc/examples/custom-server-function/tsconfig.json create mode 100644 packages/plugin-rsc/examples/custom-server-function/vite.config.ts diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts new file mode 100644 index 000000000..8c8e9fa42 --- /dev/null +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { expectNoPageError, waitForHydration } from './helper' + +test.describe('dev-custom-server-function', () => { + const f = useFixture({ + root: 'examples/custom-server-function', + mode: 'dev', + }) + defineTest(f) +}) + +test.describe('build-custom-server-function', () => { + const f = useFixture({ + root: 'examples/custom-server-function', + mode: 'build', + }) + defineTest(f) +}) + +function defineTest(f: ReturnType) { + test('built-in and custom server functions', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + + await page.getByRole('button', { name: 'Built-in: 0' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Custom: 0' }).click() + await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + }) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/package.json b/packages/plugin-rsc/examples/custom-server-function/package.json new file mode 100644 index 000000000..8811c3d65 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-custom-server-function", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^8.1.4" + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/actions.ts b/packages/plugin-rsc/examples/custom-server-function/src/actions.ts new file mode 100644 index 000000000..97358afe2 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/actions.ts @@ -0,0 +1,16 @@ +let builtinCount = 0 +let customCount = 0 + +export function getCounts() { + return { builtinCount, customCount } +} + +export async function incrementBuiltin() { + 'use server' + builtinCount++ +} + +export async function incrementCustom() { + 'use custom-server' + customCount++ +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx new file mode 100644 index 000000000..83ef7d03d --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx @@ -0,0 +1,37 @@ +import { + createFromFetch, + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc.tsx' +import { createRscRenderRequest } from './request.ts' + +const initialPayload = await createFromReadableStream(rscStream) + +function BrowserRoot() { + const [payload, setPayload] = React.useState(initialPayload) + React.useEffect(() => { + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const request = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const nextPayload = await createFromFetch(fetch(request), { + temporaryReferences, + }) + React.startTransition(() => setPayload(nextPayload)) + const { ok, data } = nextPayload.returnValue! + if (!ok) throw data + return data + }) + }, []) + return payload.root +} + +hydrateRoot(document, ) diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..e41dbc201 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx @@ -0,0 +1,60 @@ +import { + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.ts' + +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } +} + +export default { fetch: handler } + +async function handler(request: Request): Promise { + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + let returnValue: RscPayload['returnValue'] + let temporaryReferences: unknown + let status: number | undefined + + if (renderRequest.isAction && renderRequest.actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + returnValue = { ok: true, data: await action.apply(null, args) } + } catch (error) { + returnValue = { ok: false, data: error } + status = 500 + } + } + + const rscStream = renderToReadableStream( + { root: , returnValue }, + { temporaryReferences }, + ) + if (renderRequest.isRsc) { + return new Response(rscStream, { + status, + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }) + } + + const ssrEntry = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + return new Response(await ssrEntry.renderHTML(rscStream), { + status, + headers: { 'content-type': 'text/html' }, + }) +} + +if (import.meta.hot) import.meta.hot.accept() diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..48f82a68e --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx @@ -0,0 +1,21 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc.tsx' + +export async function renderHTML(rscStream: ReadableStream) { + const [ssrStream, browserStream] = rscStream.tee() + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(ssrStream) + return React.use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent, + }) + return htmlStream.pipeThrough(injectRSCPayload(browserStream)) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts new file mode 100644 index 000000000..79fee7f4e --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts @@ -0,0 +1,32 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) headers.set(HEADER_ACTION_ID, action.id) + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request) { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + return { + isRsc: true, + isAction, + actionId: request.headers.get(HEADER_ACTION_ID) || undefined, + request: new Request(url, request), + } + } + return { isRsc: false, isAction, actionId: undefined, request } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx new file mode 100644 index 000000000..2b98d100f --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx @@ -0,0 +1,22 @@ +import { getCounts, incrementBuiltin, incrementCustom } from './actions.ts' + +export function Root() { + const { builtinCount, customCount } = getCounts() + return ( + + + + + Custom Server Function + + +
+ +
+
+ +
+ + + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/tsconfig.json b/packages/plugin-rsc/examples/custom-server-function/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts new file mode 100644 index 000000000..0d4c758bc --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts @@ -0,0 +1,83 @@ +import react from '@vitejs/plugin-react' +import rsc, { getPluginApi } from '@vitejs/plugin-rsc' +import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms' +import { defineConfig, parseAstAsync, type Plugin } from 'vite' + +const owner = 'example:use-custom-server' + +export default defineConfig({ + plugins: [customServerFunction(), rsc(), react()], + environments: { + rsc: { + build: { + rollupOptions: { input: { index: './src/framework/entry.rsc.tsx' } }, + }, + }, + ssr: { + build: { + rollupOptions: { input: { index: './src/framework/entry.ssr.tsx' } }, + }, + }, + client: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.browser.tsx' }, + }, + }, + }, + }, +}) + +function customServerFunction(): Plugin { + let manager: NonNullable>['manager'] + + return { + name: 'example:custom-server-function', + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + async transform(code, id) { + if (this.environment.name !== 'rsc') { + manager.replaceServerReferenceClaim( + owner, + this.environment.name, + id, + undefined, + ) + return + } + if (!code.includes('use custom-server')) { + manager.clearServerReferenceClaims(owner, id) + return + } + + const reference = manager.resolveServerReference(id, 'rsc') + const ast = (await parseAstAsync(code)) as unknown as Parameters< + typeof transformHoistInlineDirective + >[1] + const result = transformHoistInlineDirective(code, ast, { + directive: 'use custom-server', + rejectNonAsyncFunction: true, + runtime: (value, name) => + `$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`, + }) + if (!result.output.hasChanged()) { + manager.clearServerReferenceClaims(owner, id) + return + } + + manager.clearServerReferenceClaims(owner, id) + manager.replaceServerReferenceClaim(owner, this.environment.name, id, { + ...reference, + exportNames: result.names, + }) + result.output.prepend( + `import * as $$CustomReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + }, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd5af7b2..9f1ae95dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,34 @@ importers: specifier: ^8.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/custom-server-function: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel': From 0b275177b2693df1e7279ec0b45b468099178701 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:34:51 +0900 Subject: [PATCH 03/24] docs(rsc): document custom server function example Co-authored-by: OpenCode --- packages/plugin-rsc/README.md | 1 + .../examples/custom-server-function/README.md | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/plugin-rsc/examples/custom-server-function/README.md diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index bdaefb798..202752133 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc - [`./examples/basic`](./examples/basic) - Advanced RSC features and testing - This is mainly used for e2e testing and includes various advanced RSC usages (e.g. `"use cache"` example). +- [`./examples/custom-server-function`](./examples/custom-server-function) - Third-party Server Function directive integration using server reference claims. - [`./examples/performance-track`](./examples/performance-track) - Minimal React Server Components performance track probe. - [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity. - [`./examples/react-router`](./examples/react-router) - React Router RSC integration diff --git a/packages/plugin-rsc/examples/custom-server-function/README.md b/packages/plugin-rsc/examples/custom-server-function/README.md new file mode 100644 index 000000000..a816f6a69 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/README.md @@ -0,0 +1,27 @@ +# Custom Server Function + +This example demonstrates a third-party Vite plugin implementing a custom `"use custom-server"` directive alongside the built-in `"use server"` directive. Both functions are exported from the same module and remain independently callable. + +## Background + +Server Function extensibility has two separate concerns: + +- The directive owner transforms its syntax and registers the function with the React runtime. +- `@vitejs/plugin-rsc` owns bundler-level module identity, graph visibility, manifests, and reference resolution. + +The custom plugin in [`vite.config.ts`](./vite.config.ts) transforms `"use custom-server"` and reports its exports as server reference claims. The RSC plugin aggregates those claims with its built-in `"use server"` claim instead of requiring one transform to own the entire module. This keeps custom syntax and metadata policy outside the RSC plugin while preserving a single canonical reference identity for the bundler. + +This is a low-level integration example rather than a proposed high-level Server Function API. + +## Current E2E Coverage + +[`../../e2e/custom-server-function.test.ts`](../../e2e/custom-server-function.test.ts) verifies in both development and production build modes that: + +- a built-in Server Function and a custom Server Function can coexist in one module +- each function reaches the server and updates the rendered result + +## Follow-up E2E TODO + +- Exercise claim replacement and cleanup during HMR by adding, removing, or changing a custom directive, while verifying that the built-in owner's claim remains intact and stale custom claims disappear. +- Exercise custom references through the client and SSR proxy paths, including modules that are not also statically imported by the RSC entry. +- Exercise bound arguments and closure captures through serialization, encryption, and server invocation. From bc0001592ead390c9a4f97cb330da6189d6fff28 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:15:33 +0900 Subject: [PATCH 04/24] refactor(rsc): extract server references manager Co-authored-by: OpenCode --- .../examples/browser-mode/vite.config.ts | 2 +- .../custom-server-function/vite.config.ts | 12 +- packages/plugin-rsc/src/plugin.ts | 178 ++---------------- .../src/plugins/server-reference.ts | 140 ++++++++++++++ 4 files changed, 163 insertions(+), 169 deletions(-) create mode 100644 packages/plugin-rsc/src/plugins/server-reference.ts diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts index f6c611bdd..e9bad96f0 100644 --- a/packages/plugin-rsc/examples/browser-mode/vite.config.ts +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -216,7 +216,7 @@ function rscBrowserModePlugin(): Plugin[] { return `export default {}` // no-op during dev } let code = '' - for (const meta of manager.getServerReferenceMeta()) { + for (const meta of manager.serverReferences.getMeta()) { code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` } return `export default {${code}}` diff --git a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts index 0d4c758bc..f5f2c68bd 100644 --- a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts +++ b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts @@ -38,7 +38,7 @@ function customServerFunction(): Plugin { }, async transform(code, id) { if (this.environment.name !== 'rsc') { - manager.replaceServerReferenceClaim( + manager.serverReferences.replaceClaim( owner, this.environment.name, id, @@ -47,11 +47,11 @@ function customServerFunction(): Plugin { return } if (!code.includes('use custom-server')) { - manager.clearServerReferenceClaims(owner, id) + manager.serverReferences.clearClaims(owner, id) return } - const reference = manager.resolveServerReference(id, 'rsc') + const reference = manager.serverReferences.resolve(id, 'rsc') const ast = (await parseAstAsync(code)) as unknown as Parameters< typeof transformHoistInlineDirective >[1] @@ -62,12 +62,12 @@ function customServerFunction(): Plugin { `$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`, }) if (!result.output.hasChanged()) { - manager.clearServerReferenceClaims(owner, id) + manager.serverReferences.clearClaims(owner, id) return } - manager.clearServerReferenceClaims(owner, id) - manager.replaceServerReferenceClaim(owner, this.environment.name, id, { + manager.serverReferences.clearClaims(owner, id) + manager.serverReferences.replaceClaim(owner, this.environment.name, id, { ...reference, exportNames: result.names, }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index e961442f5..88c8b4ad8 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -40,6 +40,10 @@ import { withResolvedIdProxy, } from './plugins/resolved-id-proxy' import { scanBuildStripPlugin } from './plugins/scan' +import { + ServerReferencesManager, + type ServerReferenceMeta, +} from './plugins/server-reference' import { parseCssVirtual, toCssVirtual, @@ -94,24 +98,6 @@ type ClientReferenceMeta = { groupChunkId?: string } -type ServerReferenceMeta = { - importId: string - referenceKey: string - // TODO: tree shake unused server functions - exportNames: string[] -} - -type ServerReferenceClaimMap = Map< - string, - Map> -> - -const serverReferenceClaimMaps = new WeakMap() -const serverReferenceMetaMaps = new WeakMap< - object, - Record ->() - const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -132,6 +118,8 @@ export type { RscPluginManager } * @experimental */ class RscPluginManager { + readonly serverReferences: ServerReferencesManager = + new ServerReferencesManager(this) server!: ViteDevServer config!: ResolvedConfig bundles: Record = {} @@ -158,140 +146,6 @@ class RscPluginManager { this.serverResourcesMetaMap = sortObject(this.serverResourcesMetaMap) } - resolveServerReference( - id: string, - serverEnvironmentName: string, - ): ServerReferenceMeta { - const importId = - this.config.command !== 'build' && id.includes('/node_modules/') - ? cleanUrl(id) - : id - const referenceKey = - this.config.command === 'build' - ? hashString(this.toRelativeId(importId)) - : normalizeViteImportAnalysisUrl( - this.server.environments[serverEnvironmentName]!, - importId, - ) - return { importId, referenceKey, exportNames: [] } - } - - replaceServerReferenceClaim( - owner: string, - environmentName: string, - id: string, - meta: ServerReferenceMeta | undefined, - ): void { - const claimId = - this.config.command !== 'build' && id.includes('/node_modules/') - ? cleanUrl(id) - : id - let claimMap = serverReferenceClaimMaps.get(this) - let ownerMap = claimMap?.get(claimId) - let metaMap = serverReferenceMetaMaps.get(this) - if (!metaMap) { - metaMap = {} - serverReferenceMetaMaps.set(this, metaMap) - } - if (meta?.exportNames.length) { - if (!claimMap) { - claimMap = new Map() - serverReferenceClaimMaps.set(this, claimMap) - } - if (this.config.command !== 'build' && ownerMap) { - const identityChanged = [...ownerMap.values()].some((environmentMap) => - [...environmentMap.values()].some( - (claim) => - claim.importId === meta.importId && - claim.referenceKey !== meta.referenceKey, - ), - ) - if (identityChanged) { - claimMap.delete(claimId) - delete metaMap[claimId] - ownerMap = undefined - } - } - if (!ownerMap) { - ownerMap = new Map() - claimMap.set(claimId, ownerMap) - } - let environmentMap = ownerMap.get(owner) - if (!environmentMap) { - environmentMap = new Map() - ownerMap.set(owner, environmentMap) - } - environmentMap.set(environmentName, meta) - } else if (ownerMap) { - const environmentMap = ownerMap.get(owner) - environmentMap?.delete(environmentName) - if (environmentMap?.size === 0) ownerMap.delete(owner) - if (ownerMap.size === 0) claimMap?.delete(claimId) - } else { - return - } - - ownerMap = claimMap?.get(claimId) - if (!ownerMap) { - delete metaMap[claimId] - return - } - - let aggregate: ServerReferenceMeta | undefined - const exportOwners = new Map() - for (const [claimOwner, environmentMap] of ownerMap) { - for (const claim of environmentMap.values()) { - if (!aggregate) { - aggregate = { - importId: claim.importId, - referenceKey: claim.referenceKey, - exportNames: [], - } - } else if ( - aggregate.importId !== claim.importId || - aggregate.referenceKey !== claim.referenceKey - ) { - throw new Error( - `[vite-rsc] conflicting server reference identity for '${claimId}'`, - ) - } - for (const name of claim.exportNames) { - const existingOwner = exportOwners.get(name) - if (existingOwner && existingOwner !== claimOwner) { - throw new Error( - `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, - ) - } - exportOwners.set(name, claimOwner) - } - } - } - assert(aggregate) - aggregate.exportNames = [...exportOwners.keys()].sort() - metaMap[claimId] = aggregate - } - - getServerReferenceMeta(): ServerReferenceMeta[] { - return Object.values(serverReferenceMetaMaps.get(this) ?? {}) - } - - clearServerReferenceClaims(owner: string, id: string): void { - const claimId = - this.config.command !== 'build' && id.includes('/node_modules/') - ? cleanUrl(id) - : id - const environmentNames = [ - ...(serverReferenceClaimMaps - .get(this) - ?.get(claimId) - ?.get(owner) - ?.keys() ?? []), - ] - for (const environmentName of environmentNames) { - this.replaceServerReferenceClaim(owner, environmentName, id, undefined) - } - } - toRelativeId(id: string): string { return normalizePath(path.relative(this.config.root, id)) } @@ -506,8 +360,8 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - let meta = manager - .getServerReferenceMeta() + let meta = manager.serverReferences + .getMeta() .find((meta) => meta.referenceKey === parsed.id) if (!meta) { // Server references decoded by `createFromReadableStream` with @@ -527,8 +381,8 @@ export function vitePluginRscMinimal( await this.environment.transformRequest(parsed.id) } } catch {} - meta = manager - .getServerReferenceMeta() + meta = manager.serverReferences + .getMeta() .find((meta) => meta.referenceKey === parsed.id) } if (meta) { @@ -2152,14 +2006,14 @@ function vitePluginUseServer( // filter: { code: 'use server' }, async handler(code, id) { const clearReferenceClaim = () => - manager.replaceServerReferenceClaim( + manager.serverReferences.replaceClaim( referenceOwner, this.environment.name, id, undefined, ) const clearAllReferenceClaims = () => - manager.clearServerReferenceClaims(referenceOwner, id) + manager.serverReferences.clearClaims(referenceOwner, id) if (!code.includes('use server')) { if (this.environment.name === serverEnvironmentName) { clearAllReferenceClaims() @@ -2196,7 +2050,7 @@ function vitePluginUseServer( `internal server reference created through a package imported in ${this.environment.name} environment: ${id}`, ) } - serverReference_ = manager.resolveServerReference( + serverReference_ = manager.serverReferences.resolve( id, serverEnvironmentName, ) @@ -2234,7 +2088,7 @@ function vitePluginUseServer( // The RSC transform sees both module-level and inline directives, // so its result supersedes stale discoveries from other environments. clearAllReferenceClaims() - manager.replaceServerReferenceClaim( + manager.serverReferences.replaceClaim( referenceOwner, this.environment.name, id, @@ -2292,7 +2146,7 @@ function vitePluginUseServer( clearReferenceClaim() return } - manager.replaceServerReferenceClaim( + manager.serverReferences.replaceClaim( referenceOwner, this.environment.name, id, @@ -2322,7 +2176,7 @@ function vitePluginUseServer( return { code: `export {}`, map: null } } let code = '' - for (const meta of manager.getServerReferenceMeta()) { + for (const meta of manager.serverReferences.getMeta()) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) const exports = meta.exportNames diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts new file mode 100644 index 000000000..18b0b67a4 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert' +import type { RscPluginManager } from '../plugin' +import { hashString } from './utils' +import { cleanUrl, normalizeViteImportAnalysisUrl } from './vite-utils' + +export type ServerReferenceMeta = { + importId: string + referenceKey: string + // TODO: tree shake unused server functions + exportNames: string[] +} + +type ServerReferenceClaimMap = Map< + string, + Map> +> + +export class ServerReferencesManager { + private claimMap: ServerReferenceClaimMap = new Map() + private metaMap: Record = {} + + constructor(private readonly manager: RscPluginManager) {} + + resolve(id: string, serverEnvironmentName: string): ServerReferenceMeta { + const importId = + this.manager.config.command !== 'build' && id.includes('/node_modules/') + ? cleanUrl(id) + : id + const referenceKey = + this.manager.config.command === 'build' + ? hashString(this.manager.toRelativeId(importId)) + : normalizeViteImportAnalysisUrl( + this.manager.server.environments[serverEnvironmentName]!, + importId, + ) + return { importId, referenceKey, exportNames: [] } + } + + replaceClaim( + owner: string, + environmentName: string, + id: string, + meta: ServerReferenceMeta | undefined, + ): void { + const claimId = this.normalizeId(id) + let ownerMap = this.claimMap.get(claimId) + if (meta?.exportNames.length) { + if (this.manager.config.command !== 'build' && ownerMap) { + const identityChanged = [...ownerMap.values()].some((environmentMap) => + [...environmentMap.values()].some( + (claim) => + claim.importId === meta.importId && + claim.referenceKey !== meta.referenceKey, + ), + ) + if (identityChanged) { + this.claimMap.delete(claimId) + delete this.metaMap[claimId] + ownerMap = undefined + } + } + if (!ownerMap) { + ownerMap = new Map() + this.claimMap.set(claimId, ownerMap) + } + let environmentMap = ownerMap.get(owner) + if (!environmentMap) { + environmentMap = new Map() + ownerMap.set(owner, environmentMap) + } + environmentMap.set(environmentName, meta) + } else if (ownerMap) { + const environmentMap = ownerMap.get(owner) + environmentMap?.delete(environmentName) + if (environmentMap?.size === 0) ownerMap.delete(owner) + if (ownerMap.size === 0) this.claimMap.delete(claimId) + } else { + return + } + + ownerMap = this.claimMap.get(claimId) + if (!ownerMap) { + delete this.metaMap[claimId] + return + } + + let aggregate: ServerReferenceMeta | undefined + const exportOwners = new Map() + for (const [claimOwner, environmentMap] of ownerMap) { + for (const claim of environmentMap.values()) { + if (!aggregate) { + aggregate = { + importId: claim.importId, + referenceKey: claim.referenceKey, + exportNames: [], + } + } else if ( + aggregate.importId !== claim.importId || + aggregate.referenceKey !== claim.referenceKey + ) { + throw new Error( + `[vite-rsc] conflicting server reference identity for '${claimId}'`, + ) + } + for (const name of claim.exportNames) { + const existingOwner = exportOwners.get(name) + if (existingOwner && existingOwner !== claimOwner) { + throw new Error( + `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, + ) + } + exportOwners.set(name, claimOwner) + } + } + } + assert(aggregate) + aggregate.exportNames = [...exportOwners.keys()].sort() + this.metaMap[claimId] = aggregate + } + + clearClaims(owner: string, id: string): void { + const environmentNames = [ + ...(this.claimMap.get(this.normalizeId(id))?.get(owner)?.keys() ?? []), + ] + for (const environmentName of environmentNames) { + this.replaceClaim(owner, environmentName, id, undefined) + } + } + + getMeta(): ServerReferenceMeta[] { + return Object.values(this.metaMap) + } + + private normalizeId(id: string): string { + return this.manager.config.command !== 'build' && + id.includes('/node_modules/') + ? cleanUrl(id) + : id + } +} From eabc0c848a71fc51c0ec2f5ea258a6efaada7442 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:20:23 +0900 Subject: [PATCH 05/24] nit --- packages/plugin-rsc/src/plugin.ts | 3 +-- packages/plugin-rsc/src/plugins/server-reference.ts | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 88c8b4ad8..2650910ba 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -118,8 +118,6 @@ export type { RscPluginManager } * @experimental */ class RscPluginManager { - readonly serverReferences: ServerReferencesManager = - new ServerReferencesManager(this) server!: ViteDevServer config!: ResolvedConfig bundles: Record = {} @@ -128,6 +126,7 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} + serverReferences: ServerReferencesManager = new ServerReferencesManager(this) serverResourcesMetaMap: Record = {} environmentImportMetaMap: Record< string, // sourceEnv diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 18b0b67a4..332644d50 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -10,6 +10,7 @@ export type ServerReferenceMeta = { exportNames: string[] } +// TODO: probably some default map trick or some util would make code trivial for human. type ServerReferenceClaimMap = Map< string, Map> @@ -19,7 +20,7 @@ export class ServerReferencesManager { private claimMap: ServerReferenceClaimMap = new Map() private metaMap: Record = {} - constructor(private readonly manager: RscPluginManager) {} + constructor(private manager: RscPluginManager) {} resolve(id: string, serverEnvironmentName: string): ServerReferenceMeta { const importId = From a93de80e82491a0aad956a147bfe292ab8a1e0c6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:30:35 +0900 Subject: [PATCH 06/24] test(rsc): cover custom server function proxies Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 5 + .../examples/custom-server-function/README.md | 11 +- .../custom-server-function-plugin.ts | 113 ++++++++++++++++++ .../custom-server-function/src/env.d.ts | 1 + .../src/features/action-from-client/action.ts | 7 ++ .../features/action-from-client/client.tsx | 13 ++ .../mixed-directives}/actions.ts | 0 .../src/features/mixed-directives/server.tsx | 15 +++ .../custom-server-function/src/root.tsx | 12 +- .../custom-server-function/vite.config.ts | 64 +--------- 10 files changed, 170 insertions(+), 71 deletions(-) create mode 100644 packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/env.d.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx rename packages/plugin-rsc/examples/custom-server-function/src/{ => features/mixed-directives}/actions.ts (100%) create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts index 8c8e9fa42..4b1056348 100644 --- a/packages/plugin-rsc/e2e/custom-server-function.test.ts +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -31,5 +31,10 @@ function defineTest(f: ReturnType) { await page.getByRole('button', { name: 'Custom: 0' }).click() await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + + await page.getByRole('button', { name: 'From client: 0' }).click() + await expect( + page.getByRole('button', { name: 'From client: 1' }), + ).toBeVisible() }) } diff --git a/packages/plugin-rsc/examples/custom-server-function/README.md b/packages/plugin-rsc/examples/custom-server-function/README.md index a816f6a69..1ebb20c8b 100644 --- a/packages/plugin-rsc/examples/custom-server-function/README.md +++ b/packages/plugin-rsc/examples/custom-server-function/README.md @@ -1,6 +1,6 @@ # Custom Server Function -This example demonstrates a third-party Vite plugin implementing a custom `"use custom-server"` directive alongside the built-in `"use server"` directive. Both functions are exported from the same module and remain independently callable. +This example demonstrates a third-party Vite plugin implementing a custom `"use custom-server"` directive alongside the built-in `"use server"` directive. ## Background @@ -9,7 +9,12 @@ Server Function extensibility has two separate concerns: - The directive owner transforms its syntax and registers the function with the React runtime. - `@vitejs/plugin-rsc` owns bundler-level module identity, graph visibility, manifests, and reference resolution. -The custom plugin in [`vite.config.ts`](./vite.config.ts) transforms `"use custom-server"` and reports its exports as server reference claims. The RSC plugin aggregates those claims with its built-in `"use server"` claim instead of requiring one transform to own the entire module. This keeps custom syntax and metadata policy outside the RSC plugin while preserving a single canonical reference identity for the bundler. +The custom plugin in [`custom-server-function-plugin.ts`](./custom-server-function-plugin.ts) transforms `"use custom-server"` and reports its exports as server reference claims. The RSC plugin aggregates those claims with its built-in `"use server"` claim instead of requiring one transform to own the entire module. This keeps custom syntax and metadata policy outside the RSC plugin while preserving a single canonical reference identity for the bundler. + +The example separates two import graph shapes under [`src/features`](./src/features): + +- `mixed-directives` exports inline built-in and custom Server Functions from one RSC-reachable module. +- `action-from-client` imports a module-level custom Server Function only from a Client Component. The custom plugin creates its client and SSR proxies, while the server reference manifest brings its implementation into the RSC build. This is a low-level integration example rather than a proposed high-level Server Function API. @@ -19,9 +24,9 @@ This is a low-level integration example rather than a proposed high-level Server - a built-in Server Function and a custom Server Function can coexist in one module - each function reaches the server and updates the rendered result +- a custom Server Function that is not statically imported by the RSC entry works through both the SSR and client proxy paths ## Follow-up E2E TODO - Exercise claim replacement and cleanup during HMR by adding, removing, or changing a custom directive, while verifying that the built-in owner's claim remains intact and stale custom claims disappear. -- Exercise custom references through the client and SSR proxy paths, including modules that are not also statically imported by the RSC entry. - Exercise bound arguments and closure captures through serialization, encryption, and server invocation. diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts new file mode 100644 index 000000000..795acf44c --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -0,0 +1,113 @@ +import { getPluginApi } from '@vitejs/plugin-rsc' +import { + hasDirective, + transformDirectiveProxyExport, + transformHoistInlineDirective, + transformWrapExport, +} from '@vitejs/plugin-rsc/transforms' +import { parseAstAsync, type Plugin } from 'vite' + +const directive = 'use custom-server' +const owner = 'example:use-custom-server' + +export function customServerFunctionPlugin(): Plugin { + let manager: NonNullable>['manager'] + + return { + name: 'example:custom-server-function', + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + async transform(code, id) { + const environmentName = this.environment.name + if (!code.includes(directive)) { + if (environmentName === 'rsc') { + manager.serverReferences.clearClaims(owner, id) + } else { + manager.serverReferences.replaceClaim( + owner, + environmentName, + id, + undefined, + ) + } + return + } + + const reference = manager.serverReferences.resolve(id, 'rsc') + const ast = (await parseAstAsync(code)) as unknown as Parameters< + typeof transformHoistInlineDirective + >[1] + + if (environmentName === 'rsc') { + const runtime = (value: string, name: string) => + `$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})` + const result = hasDirective(ast.body, directive) + ? transformWrapExport(code, ast, { + runtime, + rejectNonAsyncFunction: true, + }) + : transformHoistInlineDirective(code, ast, { + directive, + runtime, + rejectNonAsyncFunction: true, + }) + if (!result.output.hasChanged()) { + manager.serverReferences.clearClaims(owner, id) + return + } + + manager.serverReferences.clearClaims(owner, id) + manager.serverReferences.replaceClaim(owner, environmentName, id, { + ...reference, + exportNames: 'names' in result ? result.names : result.exportNames, + }) + result.output.prepend( + `import * as $$CustomReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + } + + const result = transformDirectiveProxyExport(ast, { + code, + directive, + rejectNonAsyncFunction: true, + runtime: (name) => + `$$CustomReactClient.createServerReference(` + + `${JSON.stringify(reference.referenceKey + '#' + name)},` + + `$$CustomReactClient.callServer,` + + `undefined,` + + (this.environment.mode === 'dev' + ? `$$CustomReactClient.findSourceMapURL,` + : `undefined,`) + + `${JSON.stringify(name)})`, + }) + if (!result?.output.hasChanged()) { + manager.serverReferences.replaceClaim( + owner, + environmentName, + id, + undefined, + ) + return + } + + manager.serverReferences.replaceClaim(owner, environmentName, id, { + ...reference, + exportNames: result.exportNames, + }) + const runtimeEnvironment = + environmentName === 'client' ? 'browser' : 'ssr' + result.output.prepend( + `import * as $$CustomReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + }, + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts b/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts new file mode 100644 index 000000000..11a0a59f1 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts @@ -0,0 +1 @@ +declare module 'server-only' diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts new file mode 100644 index 000000000..e19952c6e --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts @@ -0,0 +1,7 @@ +'use custom-server' + +import 'server-only' + +export async function incrementFromClient(previous: number) { + return previous + 1 +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx new file mode 100644 index 000000000..567d0d50c --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useActionState } from 'react' +import { incrementFromClient } from './action.ts' + +export function ActionFromClient() { + const [count, action] = useActionState(incrementFromClient, 0) + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/actions.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts similarity index 100% rename from packages/plugin-rsc/examples/custom-server-function/src/actions.ts rename to packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx new file mode 100644 index 000000000..2d8eb764f --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx @@ -0,0 +1,15 @@ +import { getCounts, incrementBuiltin, incrementCustom } from './actions.ts' + +export function MixedDirectives() { + const { builtinCount, customCount } = getCounts() + return ( + <> +
+ +
+
+ +
+ + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx index 2b98d100f..e4768402e 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx @@ -1,7 +1,7 @@ -import { getCounts, incrementBuiltin, incrementCustom } from './actions.ts' +import { ActionFromClient } from './features/action-from-client/client.tsx' +import { MixedDirectives } from './features/mixed-directives/server.tsx' export function Root() { - const { builtinCount, customCount } = getCounts() return ( @@ -10,12 +10,8 @@ export function Root() { Custom Server Function -
- -
-
- -
+ + ) diff --git a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts index f5f2c68bd..bc3e9e658 100644 --- a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts +++ b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts @@ -1,12 +1,10 @@ import react from '@vitejs/plugin-react' -import rsc, { getPluginApi } from '@vitejs/plugin-rsc' -import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms' -import { defineConfig, parseAstAsync, type Plugin } from 'vite' - -const owner = 'example:use-custom-server' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { customServerFunctionPlugin } from './custom-server-function-plugin.ts' export default defineConfig({ - plugins: [customServerFunction(), rsc(), react()], + plugins: [customServerFunctionPlugin(), rsc(), react()], environments: { rsc: { build: { @@ -27,57 +25,3 @@ export default defineConfig({ }, }, }) - -function customServerFunction(): Plugin { - let manager: NonNullable>['manager'] - - return { - name: 'example:custom-server-function', - configResolved(config) { - manager = getPluginApi(config)!.manager - }, - async transform(code, id) { - if (this.environment.name !== 'rsc') { - manager.serverReferences.replaceClaim( - owner, - this.environment.name, - id, - undefined, - ) - return - } - if (!code.includes('use custom-server')) { - manager.serverReferences.clearClaims(owner, id) - return - } - - const reference = manager.serverReferences.resolve(id, 'rsc') - const ast = (await parseAstAsync(code)) as unknown as Parameters< - typeof transformHoistInlineDirective - >[1] - const result = transformHoistInlineDirective(code, ast, { - directive: 'use custom-server', - rejectNonAsyncFunction: true, - runtime: (value, name) => - `$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`, - }) - if (!result.output.hasChanged()) { - manager.serverReferences.clearClaims(owner, id) - return - } - - manager.serverReferences.clearClaims(owner, id) - manager.serverReferences.replaceClaim(owner, this.environment.name, id, { - ...reference, - exportNames: result.names, - }) - result.output.prepend( - `import * as $$CustomReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, - ) - return { - code: result.output.toString(), - map: result.output.generateMap({ hires: 'boundary' }), - } - }, - } -} From a7c6815626e00851efec9ffc701ac8b53c42e39a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:56:02 +0900 Subject: [PATCH 07/24] test(rsc): cover server reference claim hmr Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 38 ++++++++++++++++++- .../examples/custom-server-function/README.md | 6 +-- .../custom-server-function-plugin.ts | 2 + .../src/features/mixed-directives/actions.ts | 2 + .../src/features/mixed-directives/server.tsx | 11 +++++- .../src/framework/entry.browser.tsx | 22 +++++++++-- 6 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts index 4b1056348..b7757a53c 100644 --- a/packages/plugin-rsc/e2e/custom-server-function.test.ts +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { useFixture } from './fixture' -import { expectNoPageError, waitForHydration } from './helper' +import { expectNoPageError, expectNoReload, waitForHydration } from './helper' test.describe('dev-custom-server-function', () => { const f = useFixture({ @@ -8,6 +8,42 @@ test.describe('dev-custom-server-function', () => { mode: 'dev', }) defineTest(f) + + test('updates directive ownership', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + await using _noReload = await expectNoReload(page) + + const editor = f.createEditor('src/features/mixed-directives/actions.ts') + editor.edit((source) => + source + .replace(`'use custom-server'`, `'use server'`) + .replace(`customLabel = 'Custom'`, `customLabel = 'Built-in owned'`) + .replace('customCount++', 'customCount += 10'), + ) + + await expect( + page.getByRole('button', { name: 'Built-in owned: 0' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Built-in: 0' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Built-in owned: 0' }).click() + await expect( + page.getByRole('button', { name: 'Built-in owned: 10' }), + ).toBeVisible() + + editor.reset() + await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible() + await page.getByRole('button', { name: 'Built-in: 0' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Custom: 0' }).click() + await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + }) }) test.describe('build-custom-server-function', () => { diff --git a/packages/plugin-rsc/examples/custom-server-function/README.md b/packages/plugin-rsc/examples/custom-server-function/README.md index 1ebb20c8b..94fa3044d 100644 --- a/packages/plugin-rsc/examples/custom-server-function/README.md +++ b/packages/plugin-rsc/examples/custom-server-function/README.md @@ -25,8 +25,4 @@ This is a low-level integration example rather than a proposed high-level Server - a built-in Server Function and a custom Server Function can coexist in one module - each function reaches the server and updates the rendered result - a custom Server Function that is not statically imported by the RSC entry works through both the SSR and client proxy paths - -## Follow-up E2E TODO - -- Exercise claim replacement and cleanup during HMR by adding, removing, or changing a custom directive, while verifying that the built-in owner's claim remains intact and stale custom claims disappear. -- Exercise bound arguments and closure captures through serialization, encryption, and server invocation. +- in development, an export can move from the custom owner to the built-in owner and back without reloading, losing the built-in claim, or retaining a conflicting stale claim diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index 795acf44c..2fca2ff52 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -10,6 +10,8 @@ import { parseAstAsync, type Plugin } from 'vite' const directive = 'use custom-server' const owner = 'example:use-custom-server' +// TODO: Give the custom directive observable runtime semantics so ownership +// swaps can assert the active registration path in addition to claim cleanup. export function customServerFunctionPlugin(): Plugin { let manager: NonNullable>['manager'] diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts index 97358afe2..c03de7855 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts @@ -1,6 +1,8 @@ let builtinCount = 0 let customCount = 0 +export const customLabel = 'Custom' + export function getCounts() { return { builtinCount, customCount } } diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx index 2d8eb764f..cef2178ce 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx @@ -1,4 +1,9 @@ -import { getCounts, incrementBuiltin, incrementCustom } from './actions.ts' +import { + customLabel, + getCounts, + incrementBuiltin, + incrementCustom, +} from './actions.ts' export function MixedDirectives() { const { builtinCount, customCount } = getCounts() @@ -8,7 +13,9 @@ export function MixedDirectives() {
- +
) diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx index 83ef7d03d..8049de2dd 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx @@ -16,20 +16,34 @@ const initialPayload = await createFromReadableStream(rscStream) function BrowserRoot() { const [payload, setPayload] = React.useState(initialPayload) React.useEffect(() => { + async function updatePayload( + request: Request, + temporaryReferences = createTemporaryReferenceSet(), + ) { + const nextPayload = await createFromFetch(fetch(request), { + temporaryReferences, + }) + React.startTransition(() => setPayload(nextPayload)) + return nextPayload + } + setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet() const request = createRscRenderRequest(window.location.href, { id, body: await encodeReply(args, { temporaryReferences }), }) - const nextPayload = await createFromFetch(fetch(request), { - temporaryReferences, - }) - React.startTransition(() => setPayload(nextPayload)) + const nextPayload = await updatePayload(request, temporaryReferences) const { ok, data } = nextPayload.returnValue! if (!ok) throw data return data }) + + const handleRscUpdate = () => { + void updatePayload(createRscRenderRequest(window.location.href)) + } + import.meta.hot?.on('rsc:update', handleRscUpdate) + return () => import.meta.hot?.off('rsc:update', handleRscUpdate) }, []) return payload.root } From c74f1e301d634d7b1a09947d97e7ebc6e5540f22 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:57:54 +0900 Subject: [PATCH 08/24] test(rsc): align custom example with starter Co-authored-by: OpenCode --- .../src/framework/entry.browser.tsx | 159 ++++++++++++++---- .../src/framework/entry.rsc.tsx | 122 ++++++++++---- .../src/framework/entry.ssr.tsx | 69 +++++++- .../src/framework/error-boundary.tsx | 81 +++++++++ .../src/framework/request.ts | 32 ---- .../src/framework/request.tsx | 58 +++++++ .../custom-server-function/src/root.tsx | 2 +- 7 files changed, 416 insertions(+), 107 deletions(-) create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx delete mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts create mode 100644 packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx index 8049de2dd..5b48ebdfa 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx @@ -1,51 +1,138 @@ import { - createFromFetch, createFromReadableStream, + createFromFetch, + setServerCallback, createTemporaryReferenceSet, encodeReply, - setServerCallback, } from '@vitejs/plugin-rsc/browser' import React from 'react' -import { hydrateRoot } from 'react-dom/client' +import { createRoot, hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' -import type { RscPayload } from './entry.rsc.tsx' -import { createRscRenderRequest } from './request.ts' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' -const initialPayload = await createFromReadableStream(rscStream) +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void -function BrowserRoot() { - const [payload, setPayload] = React.useState(initialPayload) - React.useEffect(() => { - async function updatePayload( - request: Request, - temporaryReferences = createTemporaryReferenceSet(), - ) { - const nextPayload = await createFromFetch(fetch(request), { - temporaryReferences, - }) - React.startTransition(() => setPayload(nextPayload)) - return nextPayload - } + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } - setServerCallback(async (id, args) => { - const temporaryReferences = createTemporaryReferenceSet() - const request = createRscRenderRequest(window.location.href, { - id, - body: await encodeReply(args, { temporaryReferences }), - }) - const nextPayload = await updatePayload(request, temporaryReferences) - const { ok, data } = nextPayload.returnValue! - if (!ok) throw data - return data + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} - const handleRscUpdate = () => { - void updatePayload(createRscRenderRequest(window.location.href)) +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) } - import.meta.hot?.on('rsc:update', handleRscUpdate) - return () => import.meta.hot?.off('rsc:update', handleRscUpdate) - }, []) - return payload.root + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } } -hydrateRoot(document, ) +main() diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx index e41dbc201..c9cf5c4b3 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx @@ -1,60 +1,122 @@ import { + renderToReadableStream, createTemporaryReferenceSet, decodeReply, loadServerAction, - renderToReadableStream, + decodeAction, + decodeFormState, } from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' import { Root } from '../root.tsx' -import { parseRenderRequest } from './request.ts' +import { parseRenderRequest } from './request.tsx' +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. root: React.ReactNode + // server action return value of non-progressive enhancement case returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState } +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. export default { fetch: handler } async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. const renderRequest = parseRenderRequest(request) request = renderRequest.request - let returnValue: RscPayload['returnValue'] - let temporaryReferences: unknown - let status: number | undefined - - if (renderRequest.isAction && renderRequest.actionId) { - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await request.formData() - : await request.text() - temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) - const action = await loadServerAction(renderRequest.actionId) - try { - returnValue = { ok: true, data: await action.apply(null, args) } - } catch (error) { - returnValue = { ok: false, data: error } - status = 500 + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } } } - const rscStream = renderToReadableStream( - { root: , returnValue }, - { temporaryReferences }, - ) + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { - status, - headers: { 'content-type': 'text/x-component;charset=utf-8' }, + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, }) } - const ssrEntry = await import.meta.viteRsc.loadModule< + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import('./entry.ssr.tsx') >('ssr', 'index') - return new Response(await ssrEntry.renderHTML(rscStream), { - status, - headers: { 'content-type': 'text/html' }, + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, }) } -if (import.meta.hot) import.meta.hot.accept() +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx index 48f82a68e..7fc5a9564 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx @@ -1,21 +1,74 @@ import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' import React from 'react' +import type { ReactFormState } from 'react-dom/client' import { renderToReadableStream } from 'react-dom/server.edge' import { injectRSCPayload } from 'rsc-html-stream/server' -import type { RscPayload } from './entry.rsc.tsx' +import type { RscPayload } from './entry.rsc' -export async function renderHTML(rscStream: ReadableStream) { - const [ssrStream, browserStream] = rscStream.tee() +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM let payload: Promise | undefined function SsrRoot() { - payload ??= createFromReadableStream(ssrStream) + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) return React.use(payload).root } + // render html (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index') - const htmlStream = await renderToReadableStream(, { - bootstrapScriptContent, - }) - return htmlStream.pipeThrough(injectRSCPayload(browserStream)) + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } } diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts deleted file mode 100644 index 79fee7f4e..000000000 --- a/packages/plugin-rsc/examples/custom-server-function/src/framework/request.ts +++ /dev/null @@ -1,32 +0,0 @@ -const URL_POSTFIX = '_.rsc' -const HEADER_ACTION_ID = 'x-rsc-action' - -export function createRscRenderRequest( - urlString: string, - action?: { id: string; body: BodyInit }, -): Request { - const url = new URL(urlString) - url.pathname += URL_POSTFIX - const headers = new Headers() - if (action) headers.set(HEADER_ACTION_ID, action.id) - return new Request(url.toString(), { - method: action ? 'POST' : 'GET', - headers, - body: action?.body, - }) -} - -export function parseRenderRequest(request: Request) { - const url = new URL(request.url) - const isAction = request.method === 'POST' - if (url.pathname.endsWith(URL_POSTFIX)) { - url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) - return { - isRsc: true, - isAction, - actionId: request.headers.get(HEADER_ACTION_ID) || undefined, - request: new Request(url, request), - } - } - return { isRsc: false, isAction, actionId: undefined, request } -} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx index e4768402e..fbc00dfee 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx @@ -1,7 +1,7 @@ import { ActionFromClient } from './features/action-from-client/client.tsx' import { MixedDirectives } from './features/mixed-directives/server.tsx' -export function Root() { +export function Root(_props: { url: URL }) { return ( From 7ace06b3c250a68df7a8d9eec98071abf2533658 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:01:22 +0900 Subject: [PATCH 09/24] test(rsc): cover progressive custom server forms Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 22 ++++++++++++++++++- .../examples/custom-server-function/README.md | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts index b7757a53c..cbfb9b1a3 100644 --- a/packages/plugin-rsc/e2e/custom-server-function.test.ts +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test' import { useFixture } from './fixture' -import { expectNoPageError, expectNoReload, waitForHydration } from './helper' +import { + expectNoPageError, + expectNoReload, + testNoJs, + waitForHydration, +} from './helper' test.describe('dev-custom-server-function', () => { const f = useFixture({ @@ -73,4 +78,19 @@ function defineTest(f: ReturnType) { page.getByRole('button', { name: 'From client: 1' }), ).toBeVisible() }) + + testNoJs('progressive forms', async ({ page }) => { + await page.goto(f.url()) + + for (const label of ['Built-in', 'Custom', 'From client']) { + const button = page.getByRole('button', { + name: new RegExp(`^${label}: \\d+$`), + }) + const count = Number((await button.textContent())!.split(': ')[1]) + await button.click() + await expect( + page.getByRole('button', { name: `${label}: ${count + 1}` }), + ).toBeVisible() + } + }) } diff --git a/packages/plugin-rsc/examples/custom-server-function/README.md b/packages/plugin-rsc/examples/custom-server-function/README.md index 94fa3044d..0718c35d4 100644 --- a/packages/plugin-rsc/examples/custom-server-function/README.md +++ b/packages/plugin-rsc/examples/custom-server-function/README.md @@ -25,4 +25,5 @@ This is a low-level integration example rather than a proposed high-level Server - a built-in Server Function and a custom Server Function can coexist in one module - each function reaches the server and updates the rendered result - a custom Server Function that is not statically imported by the RSC entry works through both the SSR and client proxy paths +- built-in, inline custom, and client-imported custom functions work as progressively enhanced forms without JavaScript - in development, an export can move from the custom owner to the built-in owner and back without reloading, losing the built-in claim, or retaining a conflicting stale claim From a5ceee0c26b562eac3c73f269e801127b7c6e9ae Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:20:31 +0900 Subject: [PATCH 10/24] refactor(rsc): simplify server reference claims Co-authored-by: OpenCode --- .../custom-server-function-plugin.ts | 25 ++---- packages/plugin-rsc/src/plugin.ts | 60 +++++--------- .../src/plugins/server-reference.ts | 81 ++++++++----------- 3 files changed, 58 insertions(+), 108 deletions(-) diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index 2fca2ff52..f65d5c183 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -23,16 +23,7 @@ export function customServerFunctionPlugin(): Plugin { async transform(code, id) { const environmentName = this.environment.name if (!code.includes(directive)) { - if (environmentName === 'rsc') { - manager.serverReferences.clearClaims(owner, id) - } else { - manager.serverReferences.replaceClaim( - owner, - environmentName, - id, - undefined, - ) - } + manager.serverReferences.deleteClaim(owner, id) return } @@ -55,12 +46,11 @@ export function customServerFunctionPlugin(): Plugin { rejectNonAsyncFunction: true, }) if (!result.output.hasChanged()) { - manager.serverReferences.clearClaims(owner, id) + manager.serverReferences.deleteClaim(owner, id) return } - manager.serverReferences.clearClaims(owner, id) - manager.serverReferences.replaceClaim(owner, environmentName, id, { + manager.serverReferences.replaceClaim(owner, id, { ...reference, exportNames: 'names' in result ? result.names : result.exportNames, }) @@ -88,16 +78,11 @@ export function customServerFunctionPlugin(): Plugin { `${JSON.stringify(name)})`, }) if (!result?.output.hasChanged()) { - manager.serverReferences.replaceClaim( - owner, - environmentName, - id, - undefined, - ) + manager.serverReferences.deleteClaim(owner, id) return } - manager.serverReferences.replaceClaim(owner, environmentName, id, { + manager.serverReferences.replaceClaim(owner, id, { ...reference, exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 2650910ba..9fd6ace95 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2000,25 +2000,11 @@ function vitePluginUseServer( name: 'rsc:use-server', transform: { // TODO: cannot use filter because handler has cleanup side effect - // (removing the current environment's reference claim) that must run - // even when directive is removed (HMR case) + // that must run even when directive is removed (HMR case) // filter: { code: 'use server' }, async handler(code, id) { - const clearReferenceClaim = () => - manager.serverReferences.replaceClaim( - referenceOwner, - this.environment.name, - id, - undefined, - ) - const clearAllReferenceClaims = () => - manager.serverReferences.clearClaims(referenceOwner, id) if (!code.includes('use server')) { - if (this.environment.name === serverEnvironmentName) { - clearAllReferenceClaims() - } else { - clearReferenceClaim() - } + manager.serverReferences.deleteClaim(referenceOwner, id) return } let ast = await parseAstAsync(code) @@ -2081,22 +2067,14 @@ function vitePluginUseServer( }) const output = result.output if (!result || !output.hasChanged()) { - clearAllReferenceClaims() + manager.serverReferences.deleteClaim(referenceOwner, id) return } - // The RSC transform sees both module-level and inline directives, - // so its result supersedes stale discoveries from other environments. - clearAllReferenceClaims() - manager.serverReferences.replaceClaim( - referenceOwner, - this.environment.name, - id, - { - ...getServerReference(), - exportNames: - 'names' in result ? result.names : result.exportNames, - }, - ) + manager.serverReferences.replaceClaim(referenceOwner, id, { + ...getServerReference(), + exportNames: + 'names' in result ? result.names : result.exportNames, + }) const importSource = resolvePackage(`${PKG_NAME}/react/rsc/server`) output.prepend( `import * as $$ReactServer from "${importSource}";\n`, @@ -2115,7 +2093,10 @@ function vitePluginUseServer( } } else { if (!hasDirective(ast.body, 'use server')) { - clearReferenceClaim() + // TODO: Inline server functions entering a non-RSC graph are + // unsupported and should throw an explicit validation error. + // https://github.com/vitejs/vite-plugin-react/issues/883#issuecomment-5029243311 + manager.serverReferences.deleteClaim(referenceOwner, id) return } const transformDirectiveProxyExport_ = withRollupError( @@ -2137,23 +2118,18 @@ function vitePluginUseServer( rejectNonAsyncFunction: true, }) if (!result) { - clearReferenceClaim() + manager.serverReferences.deleteClaim(referenceOwner, id) return } const output = result?.output if (!output?.hasChanged()) { - clearReferenceClaim() + manager.serverReferences.deleteClaim(referenceOwner, id) return } - manager.serverReferences.replaceClaim( - referenceOwner, - this.environment.name, - id, - { - ...getServerReference(), - exportNames: result.exportNames, - }, - ) + manager.serverReferences.replaceClaim(referenceOwner, id, { + ...getServerReference(), + exportNames: result.exportNames, + }) const name = this.environment.name === browserEnvironmentName ? 'browser' diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 332644d50..1d93ba3de 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -12,8 +12,13 @@ export type ServerReferenceMeta = { // TODO: probably some default map trick or some util would make code trivial for human. type ServerReferenceClaimMap = Map< + // normalized module ID string, - Map> + Map< + // claim owner + string, + ServerReferenceMeta + > > export class ServerReferencesManager { @@ -23,6 +28,7 @@ export class ServerReferencesManager { constructor(private manager: RscPluginManager) {} resolve(id: string, serverEnvironmentName: string): ServerReferenceMeta { + // TODO: normalizeId? const importId = this.manager.config.command !== 'build' && id.includes('/node_modules/') ? cleanUrl(id) @@ -39,7 +45,6 @@ export class ServerReferencesManager { replaceClaim( owner: string, - environmentName: string, id: string, meta: ServerReferenceMeta | undefined, ): void { @@ -47,12 +52,10 @@ export class ServerReferencesManager { let ownerMap = this.claimMap.get(claimId) if (meta?.exportNames.length) { if (this.manager.config.command !== 'build' && ownerMap) { - const identityChanged = [...ownerMap.values()].some((environmentMap) => - [...environmentMap.values()].some( - (claim) => - claim.importId === meta.importId && - claim.referenceKey !== meta.referenceKey, - ), + const identityChanged = [...ownerMap.values()].some( + (claim) => + claim.importId === meta.importId && + claim.referenceKey !== meta.referenceKey, ) if (identityChanged) { this.claimMap.delete(claimId) @@ -64,16 +67,9 @@ export class ServerReferencesManager { ownerMap = new Map() this.claimMap.set(claimId, ownerMap) } - let environmentMap = ownerMap.get(owner) - if (!environmentMap) { - environmentMap = new Map() - ownerMap.set(owner, environmentMap) - } - environmentMap.set(environmentName, meta) + ownerMap.set(owner, meta) } else if (ownerMap) { - const environmentMap = ownerMap.get(owner) - environmentMap?.delete(environmentName) - if (environmentMap?.size === 0) ownerMap.delete(owner) + ownerMap.delete(owner) if (ownerMap.size === 0) this.claimMap.delete(claimId) } else { return @@ -87,31 +83,29 @@ export class ServerReferencesManager { let aggregate: ServerReferenceMeta | undefined const exportOwners = new Map() - for (const [claimOwner, environmentMap] of ownerMap) { - for (const claim of environmentMap.values()) { - if (!aggregate) { - aggregate = { - importId: claim.importId, - referenceKey: claim.referenceKey, - exportNames: [], - } - } else if ( - aggregate.importId !== claim.importId || - aggregate.referenceKey !== claim.referenceKey - ) { + for (const [claimOwner, claim] of ownerMap) { + if (!aggregate) { + aggregate = { + importId: claim.importId, + referenceKey: claim.referenceKey, + exportNames: [], + } + } else if ( + aggregate.importId !== claim.importId || + aggregate.referenceKey !== claim.referenceKey + ) { + throw new Error( + `[vite-rsc] conflicting server reference identity for '${claimId}'`, + ) + } + for (const name of claim.exportNames) { + const existingOwner = exportOwners.get(name) + if (existingOwner && existingOwner !== claimOwner) { throw new Error( - `[vite-rsc] conflicting server reference identity for '${claimId}'`, + `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, ) } - for (const name of claim.exportNames) { - const existingOwner = exportOwners.get(name) - if (existingOwner && existingOwner !== claimOwner) { - throw new Error( - `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, - ) - } - exportOwners.set(name, claimOwner) - } + exportOwners.set(name, claimOwner) } } assert(aggregate) @@ -119,13 +113,8 @@ export class ServerReferencesManager { this.metaMap[claimId] = aggregate } - clearClaims(owner: string, id: string): void { - const environmentNames = [ - ...(this.claimMap.get(this.normalizeId(id))?.get(owner)?.keys() ?? []), - ] - for (const environmentName of environmentNames) { - this.replaceClaim(owner, environmentName, id, undefined) - } + deleteClaim(owner: string, id: string): void { + this.replaceClaim(owner, id, undefined) } getMeta(): ServerReferenceMeta[] { From c652ea1b6b567f29703d7128b1e1e4669571a5a6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:29:57 +0900 Subject: [PATCH 11/24] refactor(rsc): derive server reference metadata Co-authored-by: OpenCode --- .../src/plugins/server-reference.ts | 86 +++++++++++-------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 1d93ba3de..9d4fa3a6f 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -59,7 +59,6 @@ export class ServerReferencesManager { ) if (identityChanged) { this.claimMap.delete(claimId) - delete this.metaMap[claimId] ownerMap = undefined } } @@ -74,43 +73,7 @@ export class ServerReferencesManager { } else { return } - - ownerMap = this.claimMap.get(claimId) - if (!ownerMap) { - delete this.metaMap[claimId] - return - } - - let aggregate: ServerReferenceMeta | undefined - const exportOwners = new Map() - for (const [claimOwner, claim] of ownerMap) { - if (!aggregate) { - aggregate = { - importId: claim.importId, - referenceKey: claim.referenceKey, - exportNames: [], - } - } else if ( - aggregate.importId !== claim.importId || - aggregate.referenceKey !== claim.referenceKey - ) { - throw new Error( - `[vite-rsc] conflicting server reference identity for '${claimId}'`, - ) - } - for (const name of claim.exportNames) { - const existingOwner = exportOwners.get(name) - if (existingOwner && existingOwner !== claimOwner) { - throw new Error( - `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, - ) - } - exportOwners.set(name, claimOwner) - } - } - assert(aggregate) - aggregate.exportNames = [...exportOwners.keys()].sort() - this.metaMap[claimId] = aggregate + this.metaMap = deriveMetaMap(this.claimMap) } deleteClaim(owner: string, id: string): void { @@ -128,3 +91,50 @@ export class ServerReferencesManager { : id } } + +function deriveMetaMap( + claimMap: ServerReferenceClaimMap, +): Record { + return Object.fromEntries( + [...claimMap].map(([claimId, claims]) => [ + claimId, + aggregateClaims(claimId, claims), + ]), + ) +} + +function aggregateClaims( + claimId: string, + claims: Map, +): ServerReferenceMeta { + let aggregate: ServerReferenceMeta | undefined + const exportOwners = new Map() + for (const [claimOwner, claim] of claims) { + if (!aggregate) { + aggregate = { + importId: claim.importId, + referenceKey: claim.referenceKey, + exportNames: [], + } + } else if ( + aggregate.importId !== claim.importId || + aggregate.referenceKey !== claim.referenceKey + ) { + throw new Error( + `[vite-rsc] conflicting server reference identity for '${claimId}'`, + ) + } + for (const name of claim.exportNames) { + const existingOwner = exportOwners.get(name) + if (existingOwner && existingOwner !== claimOwner) { + throw new Error( + `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, + ) + } + exportOwners.set(name, claimOwner) + } + } + assert(aggregate) + aggregate.exportNames = [...exportOwners.keys()].sort() + return aggregate +} From f4ae4fab6a9caf116908f8a8b002ca81b1199d48 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:41:09 +0900 Subject: [PATCH 12/24] refactor(rsc): normalize server reference import ids Co-authored-by: OpenCode --- .../src/plugins/server-reference.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 9d4fa3a6f..8c66c1bea 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -12,7 +12,7 @@ export type ServerReferenceMeta = { // TODO: probably some default map trick or some util would make code trivial for human. type ServerReferenceClaimMap = Map< - // normalized module ID + // normalized import ID string, Map< // claim owner @@ -23,16 +23,13 @@ type ServerReferenceClaimMap = Map< export class ServerReferencesManager { private claimMap: ServerReferenceClaimMap = new Map() + // todo: nit. this Map too? private metaMap: Record = {} constructor(private manager: RscPluginManager) {} resolve(id: string, serverEnvironmentName: string): ServerReferenceMeta { - // TODO: normalizeId? - const importId = - this.manager.config.command !== 'build' && id.includes('/node_modules/') - ? cleanUrl(id) - : id + const importId = this.normalizeImportId(id) const referenceKey = this.manager.config.command === 'build' ? hashString(this.manager.toRelativeId(importId)) @@ -48,8 +45,8 @@ export class ServerReferencesManager { id: string, meta: ServerReferenceMeta | undefined, ): void { - const claimId = this.normalizeId(id) - let ownerMap = this.claimMap.get(claimId) + const importId = this.normalizeImportId(id) + let ownerMap = this.claimMap.get(importId) if (meta?.exportNames.length) { if (this.manager.config.command !== 'build' && ownerMap) { const identityChanged = [...ownerMap.values()].some( @@ -58,18 +55,18 @@ export class ServerReferencesManager { claim.referenceKey !== meta.referenceKey, ) if (identityChanged) { - this.claimMap.delete(claimId) + this.claimMap.delete(importId) ownerMap = undefined } } if (!ownerMap) { ownerMap = new Map() - this.claimMap.set(claimId, ownerMap) + this.claimMap.set(importId, ownerMap) } ownerMap.set(owner, meta) } else if (ownerMap) { ownerMap.delete(owner) - if (ownerMap.size === 0) this.claimMap.delete(claimId) + if (ownerMap.size === 0) this.claimMap.delete(importId) } else { return } @@ -84,7 +81,7 @@ export class ServerReferencesManager { return Object.values(this.metaMap) } - private normalizeId(id: string): string { + private normalizeImportId(id: string): string { return this.manager.config.command !== 'build' && id.includes('/node_modules/') ? cleanUrl(id) @@ -96,15 +93,15 @@ function deriveMetaMap( claimMap: ServerReferenceClaimMap, ): Record { return Object.fromEntries( - [...claimMap].map(([claimId, claims]) => [ - claimId, - aggregateClaims(claimId, claims), + [...claimMap].map(([importId, claims]) => [ + importId, + aggregateClaims(importId, claims), ]), ) } function aggregateClaims( - claimId: string, + importId: string, claims: Map, ): ServerReferenceMeta { let aggregate: ServerReferenceMeta | undefined @@ -121,7 +118,7 @@ function aggregateClaims( aggregate.referenceKey !== claim.referenceKey ) { throw new Error( - `[vite-rsc] conflicting server reference identity for '${claimId}'`, + `[vite-rsc] conflicting server reference identity for '${importId}'`, ) } for (const name of claim.exportNames) { From b7fa0055ffc83a1965ce7b5c4578046272c12d04 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:43:47 +0900 Subject: [PATCH 13/24] refactor(rsc): expose server reference metadata map Co-authored-by: OpenCode --- .../examples/browser-mode/vite.config.ts | 2 +- packages/plugin-rsc/src/plugin.ts | 14 +++++++------- .../plugin-rsc/src/plugins/server-reference.ts | 11 +++-------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts index e9bad96f0..989ed172b 100644 --- a/packages/plugin-rsc/examples/browser-mode/vite.config.ts +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -216,7 +216,7 @@ function rscBrowserModePlugin(): Plugin[] { return `export default {}` // no-op during dev } let code = '' - for (const meta of manager.serverReferences.getMeta()) { + for (const meta of manager.serverReferences.metaMap.values()) { code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` } return `export default {${code}}` diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9fd6ace95..4206166d1 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -359,9 +359,9 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - let meta = manager.serverReferences - .getMeta() - .find((meta) => meta.referenceKey === parsed.id) + let meta = [...manager.serverReferences.metaMap.values()].find( + (meta) => meta.referenceKey === parsed.id, + ) if (!meta) { // Server references decoded by `createFromReadableStream` with // `preserveServerReferences` can reach action loading without their @@ -380,9 +380,9 @@ export function vitePluginRscMinimal( await this.environment.transformRequest(parsed.id) } } catch {} - meta = manager.serverReferences - .getMeta() - .find((meta) => meta.referenceKey === parsed.id) + meta = [...manager.serverReferences.metaMap.values()].find( + (meta) => meta.referenceKey === parsed.id, + ) } if (meta) { return `export {}` @@ -2151,7 +2151,7 @@ function vitePluginUseServer( return { code: `export {}`, map: null } } let code = '' - for (const meta of manager.serverReferences.getMeta()) { + for (const meta of manager.serverReferences.metaMap.values()) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) const exports = meta.exportNames diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 8c66c1bea..e5b6a61ce 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -23,8 +23,7 @@ type ServerReferenceClaimMap = Map< export class ServerReferencesManager { private claimMap: ServerReferenceClaimMap = new Map() - // todo: nit. this Map too? - private metaMap: Record = {} + metaMap: Map = new Map() constructor(private manager: RscPluginManager) {} @@ -77,10 +76,6 @@ export class ServerReferencesManager { this.replaceClaim(owner, id, undefined) } - getMeta(): ServerReferenceMeta[] { - return Object.values(this.metaMap) - } - private normalizeImportId(id: string): string { return this.manager.config.command !== 'build' && id.includes('/node_modules/') @@ -91,8 +86,8 @@ export class ServerReferencesManager { function deriveMetaMap( claimMap: ServerReferenceClaimMap, -): Record { - return Object.fromEntries( +): Map { + return new Map( [...claimMap].map(([importId, claims]) => [ importId, aggregateClaims(importId, claims), From 44d73a3c78053194042a1d61f464574fca8a3546 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:45:56 +0900 Subject: [PATCH 14/24] refactor(rsc): add server reference lookup helper Co-authored-by: OpenCode --- packages/plugin-rsc/src/plugin.ts | 8 ++------ packages/plugin-rsc/src/plugins/server-reference.ts | 6 ++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 4206166d1..630c86efd 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -359,9 +359,7 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - let meta = [...manager.serverReferences.metaMap.values()].find( - (meta) => meta.referenceKey === parsed.id, - ) + let meta = manager.serverReferences.findByReferenceKey(parsed.id) if (!meta) { // Server references decoded by `createFromReadableStream` with // `preserveServerReferences` can reach action loading without their @@ -380,9 +378,7 @@ export function vitePluginRscMinimal( await this.environment.transformRequest(parsed.id) } } catch {} - meta = [...manager.serverReferences.metaMap.values()].find( - (meta) => meta.referenceKey === parsed.id, - ) + meta = manager.serverReferences.findByReferenceKey(parsed.id) } if (meta) { return `export {}` diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index e5b6a61ce..6c7416057 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -76,6 +76,12 @@ export class ServerReferencesManager { this.replaceClaim(owner, id, undefined) } + findByReferenceKey(referenceKey: string): ServerReferenceMeta | undefined { + for (const meta of this.metaMap.values()) { + if (meta.referenceKey === referenceKey) return meta + } + } + private normalizeImportId(id: string): string { return this.manager.config.command !== 'build' && id.includes('/node_modules/') From f36fe28305393489bfead70f7a36d9c6023e439a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:49:57 +0900 Subject: [PATCH 15/24] refactor(rsc): separate server reference claim deletion Co-authored-by: OpenCode --- .../src/plugins/server-reference.ts | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 6c7416057..6127c4e68 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -39,41 +39,35 @@ export class ServerReferencesManager { return { importId, referenceKey, exportNames: [] } } - replaceClaim( - owner: string, - id: string, - meta: ServerReferenceMeta | undefined, - ): void { + replaceClaim(owner: string, id: string, meta: ServerReferenceMeta): void { const importId = this.normalizeImportId(id) let ownerMap = this.claimMap.get(importId) - if (meta?.exportNames.length) { - if (this.manager.config.command !== 'build' && ownerMap) { - const identityChanged = [...ownerMap.values()].some( - (claim) => - claim.importId === meta.importId && - claim.referenceKey !== meta.referenceKey, - ) - if (identityChanged) { - this.claimMap.delete(importId) - ownerMap = undefined - } - } - if (!ownerMap) { - ownerMap = new Map() - this.claimMap.set(importId, ownerMap) + if (this.manager.config.command !== 'build' && ownerMap) { + const identityChanged = [...ownerMap.values()].some( + (claim) => + claim.importId === meta.importId && + claim.referenceKey !== meta.referenceKey, + ) + if (identityChanged) { + this.claimMap.delete(importId) + ownerMap = undefined } - ownerMap.set(owner, meta) - } else if (ownerMap) { - ownerMap.delete(owner) - if (ownerMap.size === 0) this.claimMap.delete(importId) - } else { - return } + if (!ownerMap) { + ownerMap = new Map() + this.claimMap.set(importId, ownerMap) + } + ownerMap.set(owner, meta) this.metaMap = deriveMetaMap(this.claimMap) } deleteClaim(owner: string, id: string): void { - this.replaceClaim(owner, id, undefined) + const importId = this.normalizeImportId(id) + const ownerMap = this.claimMap.get(importId) + if (!ownerMap) return + ownerMap.delete(owner) + if (ownerMap.size === 0) this.claimMap.delete(importId) + this.metaMap = deriveMetaMap(this.claimMap) } findByReferenceKey(referenceKey: string): ServerReferenceMeta | undefined { From cfc678960e1abcdfa5a4d72e106d1458230d56d3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:51:57 +0900 Subject: [PATCH 16/24] refactor(rsc): simplify server reference claim replacement Co-authored-by: OpenCode --- .../custom-server-function/custom-server-function-plugin.ts | 4 ++-- packages/plugin-rsc/src/plugin.ts | 4 ++-- packages/plugin-rsc/src/plugins/server-reference.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index f65d5c183..1a762daa8 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -50,7 +50,7 @@ export function customServerFunctionPlugin(): Plugin { return } - manager.serverReferences.replaceClaim(owner, id, { + manager.serverReferences.replaceClaim(owner, { ...reference, exportNames: 'names' in result ? result.names : result.exportNames, }) @@ -82,7 +82,7 @@ export function customServerFunctionPlugin(): Plugin { return } - manager.serverReferences.replaceClaim(owner, id, { + manager.serverReferences.replaceClaim(owner, { ...reference, exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 630c86efd..e153351ee 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2066,7 +2066,7 @@ function vitePluginUseServer( manager.serverReferences.deleteClaim(referenceOwner, id) return } - manager.serverReferences.replaceClaim(referenceOwner, id, { + manager.serverReferences.replaceClaim(referenceOwner, { ...getServerReference(), exportNames: 'names' in result ? result.names : result.exportNames, @@ -2122,7 +2122,7 @@ function vitePluginUseServer( manager.serverReferences.deleteClaim(referenceOwner, id) return } - manager.serverReferences.replaceClaim(referenceOwner, id, { + manager.serverReferences.replaceClaim(referenceOwner, { ...getServerReference(), exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 6127c4e68..2787fd947 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -39,8 +39,8 @@ export class ServerReferencesManager { return { importId, referenceKey, exportNames: [] } } - replaceClaim(owner: string, id: string, meta: ServerReferenceMeta): void { - const importId = this.normalizeImportId(id) + replaceClaim(owner: string, meta: ServerReferenceMeta): void { + const importId = meta.importId let ownerMap = this.claimMap.get(importId) if (this.manager.config.command !== 'build' && ownerMap) { const identityChanged = [...ownerMap.values()].some( From de91c9bdf8db51626dd55b7b6a4d41b2aa1dc013 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:03:19 +0900 Subject: [PATCH 17/24] refactor(rsc): simplify server reference claim map Co-authored-by: OpenCode --- .../src/plugins/server-reference.ts | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 2787fd947..2f59f8df3 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -10,8 +10,7 @@ export type ServerReferenceMeta = { exportNames: string[] } -// TODO: probably some default map trick or some util would make code trivial for human. -type ServerReferenceClaimMap = Map< +type ServerReferenceClaimMap = DefaultMap< // normalized import ID string, Map< @@ -22,7 +21,7 @@ type ServerReferenceClaimMap = Map< > export class ServerReferencesManager { - private claimMap: ServerReferenceClaimMap = new Map() + claimMap: ServerReferenceClaimMap = new DefaultMap(() => new Map()) metaMap: Map = new Map() constructor(private manager: RscPluginManager) {} @@ -40,33 +39,17 @@ export class ServerReferencesManager { } replaceClaim(owner: string, meta: ServerReferenceMeta): void { - const importId = meta.importId - let ownerMap = this.claimMap.get(importId) - if (this.manager.config.command !== 'build' && ownerMap) { - const identityChanged = [...ownerMap.values()].some( - (claim) => - claim.importId === meta.importId && - claim.referenceKey !== meta.referenceKey, - ) - if (identityChanged) { - this.claimMap.delete(importId) - ownerMap = undefined - } - } - if (!ownerMap) { - ownerMap = new Map() - this.claimMap.set(importId, ownerMap) - } - ownerMap.set(owner, meta) + this.claimMap.get(meta.importId).set(owner, meta) this.metaMap = deriveMetaMap(this.claimMap) } deleteClaim(owner: string, id: string): void { const importId = this.normalizeImportId(id) const ownerMap = this.claimMap.get(importId) - if (!ownerMap) return ownerMap.delete(owner) - if (ownerMap.size === 0) this.claimMap.delete(importId) + if (ownerMap.size === 0) { + this.claimMap.delete(importId) + } this.metaMap = deriveMetaMap(this.claimMap) } @@ -130,3 +113,18 @@ function aggregateClaims( aggregate.exportNames = [...exportOwners.keys()].sort() return aggregate } + +class DefaultMap extends Map { + constructor(private createDefault: (key: K) => V) { + super() + } + + override get(key: K): V { + if (super.has(key)) { + return super.get(key)! + } + const value = this.createDefault(key) + this.set(key, value) + return value + } +} From 0757c637afe4817b19851dcbe0db3a23ab57b302 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:11:30 +0900 Subject: [PATCH 18/24] refactor(rsc): clarify server reference aggregation Co-authored-by: OpenCode --- packages/plugin-rsc/src/plugins/server-reference.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 2f59f8df3..657b9e22c 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -70,12 +70,11 @@ export class ServerReferencesManager { function deriveMetaMap( claimMap: ServerReferenceClaimMap, ): Map { - return new Map( - [...claimMap].map(([importId, claims]) => [ - importId, - aggregateClaims(importId, claims), - ]), - ) + const metaMap = new Map() + for (const [importId, claims] of claimMap) { + metaMap.set(importId, aggregateClaims(importId, claims)) + } + return metaMap } function aggregateClaims( @@ -85,6 +84,8 @@ function aggregateClaims( let aggregate: ServerReferenceMeta | undefined const exportOwners = new Map() for (const [claimOwner, claim] of claims) { + // A mismatch indicates incompatible plugin integration claims, not an + // application authoring error. if (!aggregate) { aggregate = { importId: claim.importId, From 2733ceeee040833bda5efe12399d8c5b74a8b5d1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:14:34 +0900 Subject: [PATCH 19/24] refactor(rsc): use plugin names as claim owners Co-authored-by: OpenCode --- .../custom-server-function-plugin.ts | 17 ++++++++++------- packages/plugin-rsc/src/plugin.ts | 18 +++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index 1a762daa8..baaf6c6ae 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -8,22 +8,25 @@ import { import { parseAstAsync, type Plugin } from 'vite' const directive = 'use custom-server' -const owner = 'example:use-custom-server' +const pluginName = 'example:custom-server-function' +// This intentionally mirrors the built-in "use server" pipeline through the +// public integration APIs, but uses a separate directive and plugin name and +// omits export-all expansion and action encryption. // TODO: Give the custom directive observable runtime semantics so ownership // swaps can assert the active registration path in addition to claim cleanup. export function customServerFunctionPlugin(): Plugin { let manager: NonNullable>['manager'] return { - name: 'example:custom-server-function', + name: pluginName, configResolved(config) { manager = getPluginApi(config)!.manager }, async transform(code, id) { const environmentName = this.environment.name if (!code.includes(directive)) { - manager.serverReferences.deleteClaim(owner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } @@ -46,11 +49,11 @@ export function customServerFunctionPlugin(): Plugin { rejectNonAsyncFunction: true, }) if (!result.output.hasChanged()) { - manager.serverReferences.deleteClaim(owner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(owner, { + manager.serverReferences.replaceClaim(pluginName, { ...reference, exportNames: 'names' in result ? result.names : result.exportNames, }) @@ -78,11 +81,11 @@ export function customServerFunctionPlugin(): Plugin { `${JSON.stringify(name)})`, }) if (!result?.output.hasChanged()) { - manager.serverReferences.deleteClaim(owner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(owner, { + manager.serverReferences.replaceClaim(pluginName, { ...reference, exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index e153351ee..464fc4bd9 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1989,18 +1989,18 @@ function vitePluginUseServer( useServerPluginOptions.environment?.browser ?? 'client' const debug = createDebug('vite-rsc:use-server') - const referenceOwner = 'rsc:use-server' + const pluginName = 'rsc:use-server' return [ { - name: 'rsc:use-server', + name: pluginName, transform: { // TODO: cannot use filter because handler has cleanup side effect // that must run even when directive is removed (HMR case) // filter: { code: 'use server' }, async handler(code, id) { if (!code.includes('use server')) { - manager.serverReferences.deleteClaim(referenceOwner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } let ast = await parseAstAsync(code) @@ -2063,10 +2063,10 @@ function vitePluginUseServer( }) const output = result.output if (!result || !output.hasChanged()) { - manager.serverReferences.deleteClaim(referenceOwner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(referenceOwner, { + manager.serverReferences.replaceClaim(pluginName, { ...getServerReference(), exportNames: 'names' in result ? result.names : result.exportNames, @@ -2092,7 +2092,7 @@ function vitePluginUseServer( // TODO: Inline server functions entering a non-RSC graph are // unsupported and should throw an explicit validation error. // https://github.com/vitejs/vite-plugin-react/issues/883#issuecomment-5029243311 - manager.serverReferences.deleteClaim(referenceOwner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } const transformDirectiveProxyExport_ = withRollupError( @@ -2114,15 +2114,15 @@ function vitePluginUseServer( rejectNonAsyncFunction: true, }) if (!result) { - manager.serverReferences.deleteClaim(referenceOwner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } const output = result?.output if (!output?.hasChanged()) { - manager.serverReferences.deleteClaim(referenceOwner, id) + manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(referenceOwner, { + manager.serverReferences.replaceClaim(pluginName, { ...getServerReference(), exportNames: result.exportNames, }) From 577c4a9e49fc03d599b06237b165f2f2421d5d59 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:21:32 +0900 Subject: [PATCH 20/24] refactor(rsc): expose plugin manager type Co-authored-by: OpenCode --- .../custom-server-function/custom-server-function-plugin.ts | 4 ++-- packages/plugin-rsc/src/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index baaf6c6ae..c8ff5f7dc 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -1,4 +1,4 @@ -import { getPluginApi } from '@vitejs/plugin-rsc' +import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' import { hasDirective, transformDirectiveProxyExport, @@ -16,7 +16,7 @@ const pluginName = 'example:custom-server-function' // TODO: Give the custom directive observable runtime semantics so ownership // swaps can assert the active registration path in addition to claim cleanup. export function customServerFunctionPlugin(): Plugin { - let manager: NonNullable>['manager'] + let manager: RscPluginManager return { name: pluginName, diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index b3c2a7f0b..f1f6f65d8 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -3,4 +3,5 @@ export { type RscPluginOptions, getPluginApi, type PluginApi, + type RscPluginManager, } from './plugin' From 1bf72c22938eb3658b31e05bc8f035c134c0453d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:24:36 +0900 Subject: [PATCH 21/24] test(rsc): localize server-only type suppression Co-authored-by: OpenCode --- packages/plugin-rsc/examples/custom-server-function/src/env.d.ts | 1 - .../src/features/action-from-client/action.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 packages/plugin-rsc/examples/custom-server-function/src/env.d.ts diff --git a/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts b/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts deleted file mode 100644 index 11a0a59f1..000000000 --- a/packages/plugin-rsc/examples/custom-server-function/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'server-only' diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts index e19952c6e..5562fffd5 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts @@ -1,5 +1,6 @@ 'use custom-server' +// @ts-ignore -- virtualized by @vitejs/plugin-rsc import 'server-only' export async function incrementFromClient(previous: number) { From 44cea03de394e7ef09472ee4a7c6268478db09f4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:29:08 +0900 Subject: [PATCH 22/24] test(rsc): reset custom server function state Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 31 +++++++++---------- .../src/features/mixed-directives/actions.ts | 6 ++++ .../src/features/mixed-directives/server.tsx | 4 +++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts index cbfb9b1a3..a1bcfb0f1 100644 --- a/packages/plugin-rsc/e2e/custom-server-function.test.ts +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { useFixture } from './fixture' import { expectNoPageError, @@ -64,7 +64,15 @@ function defineTest(f: ReturnType) { using _ = expectNoPageError(page) await page.goto(f.url()) await waitForHydration(page) + await testActions(page) + }) + testNoJs('progressive forms', async ({ page }) => { + await page.goto(f.url()) + await testActions(page) + }) + + async function testActions(page: Page) { await page.getByRole('button', { name: 'Built-in: 0' }).click() await expect( page.getByRole('button', { name: 'Built-in: 1' }), @@ -77,20 +85,11 @@ function defineTest(f: ReturnType) { await expect( page.getByRole('button', { name: 'From client: 1' }), ).toBeVisible() - }) - testNoJs('progressive forms', async ({ page }) => { - await page.goto(f.url()) - - for (const label of ['Built-in', 'Custom', 'From client']) { - const button = page.getByRole('button', { - name: new RegExp(`^${label}: \\d+$`), - }) - const count = Number((await button.textContent())!.split(': ')[1]) - await button.click() - await expect( - page.getByRole('button', { name: `${label}: ${count + 1}` }), - ).toBeVisible() - } - }) + await page.getByRole('button', { name: 'Reset' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 0' }), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible() + } } diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts index c03de7855..8d559b043 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts @@ -16,3 +16,9 @@ export async function incrementCustom() { 'use custom-server' customCount++ } + +export async function resetCounts() { + 'use server' + builtinCount = 0 + customCount = 0 +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx index cef2178ce..67257b6a8 100644 --- a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx @@ -3,6 +3,7 @@ import { getCounts, incrementBuiltin, incrementCustom, + resetCounts, } from './actions.ts' export function MixedDirectives() { @@ -17,6 +18,9 @@ export function MixedDirectives() { {customLabel}: {customCount} +
+ +
) } From a96385153cd8dec9bdf3c55ad106db75a5a20fba Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:56:13 +0900 Subject: [PATCH 23/24] fix(rsc): preserve server reference claim ids Co-authored-by: OpenCode --- .../custom-server-function-plugin.ts | 4 ++-- packages/plugin-rsc/src/plugin.ts | 4 ++-- .../src/plugins/server-reference.ts | 23 +++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts index c8ff5f7dc..6b21c4cc4 100644 --- a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -53,7 +53,7 @@ export function customServerFunctionPlugin(): Plugin { return } - manager.serverReferences.replaceClaim(pluginName, { + manager.serverReferences.replaceClaim(pluginName, id, { ...reference, exportNames: 'names' in result ? result.names : result.exportNames, }) @@ -85,7 +85,7 @@ export function customServerFunctionPlugin(): Plugin { return } - manager.serverReferences.replaceClaim(pluginName, { + manager.serverReferences.replaceClaim(pluginName, id, { ...reference, exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 36935c7b0..a87a471ee 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2066,7 +2066,7 @@ function vitePluginUseServer( manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(pluginName, { + manager.serverReferences.replaceClaim(pluginName, id, { ...getServerReference(), exportNames: result.referenceNames, }) @@ -2121,7 +2121,7 @@ function vitePluginUseServer( manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferences.replaceClaim(pluginName, { + manager.serverReferences.replaceClaim(pluginName, id, { ...getServerReference(), exportNames: result.exportNames, }) diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index 657b9e22c..e428246b4 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -11,7 +11,7 @@ export type ServerReferenceMeta = { } type ServerReferenceClaimMap = DefaultMap< - // normalized import ID + // exact transform module ID string, Map< // claim owner @@ -38,17 +38,16 @@ export class ServerReferencesManager { return { importId, referenceKey, exportNames: [] } } - replaceClaim(owner: string, meta: ServerReferenceMeta): void { - this.claimMap.get(meta.importId).set(owner, meta) + replaceClaim(owner: string, id: string, meta: ServerReferenceMeta): void { + this.claimMap.get(id).set(owner, meta) this.metaMap = deriveMetaMap(this.claimMap) } deleteClaim(owner: string, id: string): void { - const importId = this.normalizeImportId(id) - const ownerMap = this.claimMap.get(importId) + const ownerMap = this.claimMap.get(id) ownerMap.delete(owner) if (ownerMap.size === 0) { - this.claimMap.delete(importId) + this.claimMap.delete(id) } this.metaMap = deriveMetaMap(this.claimMap) } @@ -70,8 +69,18 @@ export class ServerReferencesManager { function deriveMetaMap( claimMap: ServerReferenceClaimMap, ): Map { + const normalizedClaimMap = new DefaultMap< + string, + Map + >(() => new Map()) + for (const claims of claimMap.values()) { + for (const [owner, claim] of claims) { + normalizedClaimMap.get(claim.importId).set(owner, claim) + } + } + const metaMap = new Map() - for (const [importId, claims] of claimMap) { + for (const [importId, claims] of normalizedClaimMap) { metaMap.set(importId, aggregateClaims(importId, claims)) } return metaMap From 7637970d8d8aa05db8dc80c0f2f44ffca71c71cc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:25:05 +0900 Subject: [PATCH 24/24] test(rsc): clarify custom action ownership flow Co-authored-by: OpenCode --- .../e2e/custom-server-function.test.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts index a1bcfb0f1..f490f3f67 100644 --- a/packages/plugin-rsc/e2e/custom-server-function.test.ts +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -14,37 +14,47 @@ test.describe('dev-custom-server-function', () => { }) defineTest(f) - test('updates directive ownership', async ({ page }) => { + test('moves an action between custom and built-in ownership', async ({ + page, + }) => { using _ = expectNoPageError(page) await page.goto(f.url()) await waitForHydration(page) await using _noReload = await expectNoReload(page) const editor = f.createEditor('src/features/mixed-directives/actions.ts') + // Switch one export from the custom plugin to the built-in plugin. HMR + // must remove the custom claim while preserving the module's other claims. editor.edit((source) => source .replace(`'use custom-server'`, `'use server'`) - .replace(`customLabel = 'Custom'`, `customLabel = 'Built-in owned'`) + .replace( + `customLabel = 'Custom'`, + `customLabel = 'Custom changed to built-in'`, + ) .replace('customCount++', 'customCount += 10'), ) await expect( - page.getByRole('button', { name: 'Built-in owned: 0' }), + page.getByRole('button', { name: 'Custom changed to built-in: 0' }), ).toBeVisible() - await page.getByRole('button', { name: 'Built-in: 0' }).click() + await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click() await expect( - page.getByRole('button', { name: 'Built-in: 1' }), + page.getByRole('button', { name: 'Built-in: 1', exact: true }), ).toBeVisible() - await page.getByRole('button', { name: 'Built-in owned: 0' }).click() + await page + .getByRole('button', { name: 'Custom changed to built-in: 0' }) + .click() await expect( - page.getByRole('button', { name: 'Built-in owned: 10' }), + page.getByRole('button', { name: 'Custom changed to built-in: 10' }), ).toBeVisible() + // Switch the export back and verify neither owner retained stale state. editor.reset() await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible() - await page.getByRole('button', { name: 'Built-in: 0' }).click() + await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click() await expect( - page.getByRole('button', { name: 'Built-in: 1' }), + page.getByRole('button', { name: 'Built-in: 1', exact: true }), ).toBeVisible() await page.getByRole('button', { name: 'Custom: 0' }).click() await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() @@ -73,6 +83,7 @@ function defineTest(f: ReturnType) { }) async function testActions(page: Page) { + // The first two actions are inline functions in one RSC-reachable module. await page.getByRole('button', { name: 'Built-in: 0' }).click() await expect( page.getByRole('button', { name: 'Built-in: 1' }), @@ -81,6 +92,8 @@ function defineTest(f: ReturnType) { await page.getByRole('button', { name: 'Custom: 0' }).click() await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + // This module is only imported by a Client Component, so its implementation + // reaches the RSC build through the aggregated server reference manifest. await page.getByRole('button', { name: 'From client: 0' }).click() await expect( page.getByRole('button', { name: 'From client: 1' }),