diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index 3cfb941b0..aa852dbf9 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -32,6 +32,7 @@ npm create vite@latest -- --template rsc - [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content. - [`./examples/no-ssr`](./examples/no-ssr) - RSC application without an SSR environment. - [`./examples/client-first`](./examples/client-first) - Experimental client-owned page that consumes RSC function results. +- [`./examples/action-reachability`](./examples/action-reachability) - Cross-environment module reachability for a server action wrapped in an ordinary client-side object. - [`./examples/browser-mode`](./examples/browser-mode) - Advanced setup that runs both RSC and React client environments in the browser with custom module loading. - [`./examples/performance-track`](./examples/performance-track) - Minimal React Server Components performance track probe. - [`./examples/react-router`](./examples/react-router) - React Router RSC integration diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts new file mode 100644 index 000000000..48f639513 --- /dev/null +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe('build', () => { + const f = useFixture({ + root: 'examples/action-reachability', + mode: 'build', + }) + + test('executes a retained action through /a', async ({ page }) => { + // The production manifest redispatches the /b request through /a. + await page.goto(f.url('/a')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action A' }).click() + await page.getByRole('link', { name: '/b' }).click() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Run saved action' }), + ).toBeEnabled() + + await page.getByRole('button', { name: 'Run saved action' }).click() + await expect( + page.getByText('Result: ACTION_A_OK:MIDDLEWARE_A'), + ).toBeVisible() + await expect(page).toHaveURL(f.url('/b')) + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + }) +}) + +test.describe('dev', () => { + const f = useFixture({ + root: 'examples/action-reachability', + mode: 'dev', + }) + + test('executes a retained action through /b', async ({ page }) => { + // Development has no route manifest, so the same request stays on /b. + await page.goto(f.url('/a')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action A' }).click() + await page.getByRole('link', { name: '/b' }).click() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Run saved action' }), + ).toBeEnabled() + + await page.getByRole('button', { name: 'Run saved action' }).click() + await expect( + page.getByText('Result: ACTION_A_OK:MIDDLEWARE_B'), + ).toBeVisible() + await expect(page).toHaveURL(f.url('/b')) + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + }) +}) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md new file mode 100644 index 000000000..df14afb78 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -0,0 +1,43 @@ +# Cross-Environment Action Reachability + +This example demonstrates route-aware dispatch for a retained server action. Action A is reachable from route `/a`'s application graph but not from route `/b`'s graph. The browser can still retain its server reference, navigate to `/b`, and invoke it there. + +The example follows this sequence: + +1. Open `/a` and save action A in a shared browser module. +2. Navigate to `/b`, retaining the saved server reference. +3. Invoke action A through an explicit-ID action request to `/b`. + +| Mode | Action executes through | Result | Rendered page | +| ----------- | ----------------------- | -------------------------- | ------------- | +| Production | `/a` middleware | `ACTION_A_OK:MIDDLEWARE_A` | `/b` | +| Development | `/b` middleware | `ACTION_A_OK:MIDDLEWARE_B` | `/b` | + +In production, a generated route-action manifest lets the RSC handler redispatch the action request through a route whose graph can load the action. This example enables manifest routing only in production, so development stays on the current route. + +## Application graphs + +For simplicity, the app routes and their graph roots are declared manually. Route `/a` reaches action A through a Client Component and an ordinary runtime return value: + +```text +src/app/a/page.tsx + -> client.tsx ("use client") + -> action-indirect.ts returns actionA + -> action.tsx ("use server") +``` + +## Manifest generation + +During the RSC build, the manifest plugin traverses each route graph and records directly reachable server reference IDs and reachable client reference keys. During the client build, it calls the experimental `manager.getClientToServerReferenceReachability(this)` API to map those client references to server reference IDs. For each route, it unions the directly reachable IDs with the IDs reachable through its client references. + +After all environment builds finish, the plugin installs the mapping in the RSC output for runtime routing. + +## Request redispatch + +For the production scenario above, the RSC handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. + +Development skips route-aware redispatch. The handler executes action A on `/b`, so the action observes `MIDDLEWARE_B`. + +## Protocol scope + +For simplicity, route-aware redispatch covers only hydrated action calls that carry an explicit action ID. Progressive multipart form actions still use the baseline `decodeAction()` path without manifest routing. diff --git a/packages/plugin-rsc/examples/action-reachability/package.json b/packages/plugin-rsc/examples/action-reachability/package.json new file mode 100644 index 000000000..a7714fd6e --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-action-reachability", + "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.5" + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts new file mode 100644 index 000000000..11dd02ce4 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts @@ -0,0 +1,129 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' +import { normalizePath, type Plugin } from 'vite' + +// TODO: A framework would derive these graph roots from the runtime route +// convention. This example lists them again for simplicity. +const routes = { + '/a': ['./src/app/root.tsx', './src/app/a/page.tsx'], + '/b': ['./src/app/root.tsx', './src/app/b/page.tsx'], +} + +const ROUTE_ACTION_MANIFEST_ID = 'virtual:route-action-manifest' +const ROUTE_ACTION_MANIFEST_FILE = '__route_action_manifest.js' + +export function routeActionManifestPlugin(): Plugin { + let manager: RscPluginManager + const routeClientReferenceKeys = new Map>() + const routeDirectServerReferenceIds = new Map>() + let routeActionManifest: Record = {} + + return { + name: 'route-action-manifest', + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + resolveId(source) { + if (source === ROUTE_ACTION_MANIFEST_ID) { + return this.environment.mode === 'build' + ? { id: source, external: true } + : '\0' + source + } + }, + load(id) { + if (id === '\0' + ROUTE_ACTION_MANIFEST_ID) { + return 'export default null' + } + }, + generateBundle() { + if (this.environment.name === 'rsc') { + // Collect each route's direct actions and reachable Client Components. + for (const [route, roots] of Object.entries(routes)) { + const clientReferenceKeys = new Set() + const directServerReferenceIds = new Set() + const visited = new Set() + const queue = roots.map((source) => + normalizePath(path.resolve(source)), + ) + for (let index = 0; index < queue.length; index++) { + const id = queue[index]! + if (visited.has(id)) continue + visited.add(id) + + const clientReference = manager.clientReferenceMetaMap[id] + if (clientReference) { + clientReferenceKeys.add(clientReference.referenceKey) + } + + const serverReference = manager.serverReferences.metaMap.get(id) + if (serverReference) { + for (const exportName of serverReference.exportNames) { + directServerReferenceIds.add( + `${serverReference.referenceKey}#${exportName}`, + ) + } + } + + const info = this.getModuleInfo(id) + if (info) { + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + } + routeClientReferenceKeys.set(route, clientReferenceKeys) + routeDirectServerReferenceIds.set(route, directServerReferenceIds) + } + return + } + + if (this.environment.name !== 'client') return + // Join RSC route reachability with the final client graph relation. + const reachabilityByReferenceKey = new Map( + manager + .getClientToServerReferenceReachability(this) + .map((entry) => [entry.referenceKey, entry.serverReferenceIds]), + ) + routeActionManifest = Object.fromEntries( + Object.keys(routes).map((route) => { + const actionIds = new Set(routeDirectServerReferenceIds.get(route)) + for (const referenceKey of routeClientReferenceKeys.get(route) ?? + []) { + for (const actionId of reachabilityByReferenceKey.get( + referenceKey, + ) ?? []) { + actionIds.add(actionId) + } + } + return [route, [...actionIds].sort()] + }), + ) + }, + // Leave the virtual import external, then point it at an ESM sidecar + // generated after the later client build. + renderChunk(code, chunk) { + if (code.includes(ROUTE_ACTION_MANIFEST_ID)) { + let relativePath = path.posix.relative( + path.posix.dirname(chunk.fileName), + ROUTE_ACTION_MANIFEST_FILE, + ) + if (!relativePath.startsWith('.')) { + relativePath = './' + relativePath + } + return { + code: code.replaceAll(ROUTE_ACTION_MANIFEST_ID, relativePath), + } + } + }, + buildApp: { + order: 'post', + async handler(builder) { + // The client graph is available only after the RSC output was emitted. + const outDir = builder.config.environments.rsc.build.outDir + await fs.promises.writeFile( + path.join(outDir, ROUTE_ACTION_MANIFEST_FILE), + `export default ${JSON.stringify(routeActionManifest, null, 2)}\n`, + ) + }, + }, + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/a/action-indirect.ts b/packages/plugin-rsc/examples/action-reachability/src/app/a/action-indirect.ts new file mode 100644 index 000000000..0d2598cc2 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/a/action-indirect.ts @@ -0,0 +1,7 @@ +import { actionA } from './action.tsx' + +// Return the server reference through ordinary runtime value flow, which +// import/export binding reconstruction cannot follow. +export function getActionA() { + return actionA +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/a/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/a/action.tsx new file mode 100644 index 000000000..8c193a0e2 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/a/action.tsx @@ -0,0 +1,7 @@ +'use server' + +import { getRequestContext } from '../request-context.ts' + +export async function actionA() { + return `ACTION_A_OK:${getRequestContext().middlewareTag}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/a/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/a/client.tsx new file mode 100644 index 000000000..92be151c1 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/a/client.tsx @@ -0,0 +1,24 @@ +'use client' + +import React from 'react' +import { getSavedAction, setSavedAction } from '../saved-action.ts' +import { getActionA } from './action-indirect.ts' + +export function ActionA() { + const [result, setResult] = React.useState('none') + const savedAction = getSavedAction() + const actionA = getActionA() + return ( +
+ + + +

Result: {result}

+
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/a/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/app/a/middleware.ts new file mode 100644 index 000000000..befad60c1 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/a/middleware.ts @@ -0,0 +1,5 @@ +import type { RouteMiddleware } from '../../framework/middleware.ts' +import { runWithRequestContext } from '../request-context.ts' + +export const middleware: RouteMiddleware = (_request, next) => + runWithRequestContext({ middlewareTag: 'MIDDLEWARE_A' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/a/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/a/page.tsx new file mode 100644 index 000000000..10aa68a4c --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/a/page.tsx @@ -0,0 +1,10 @@ +import { ActionA } from './client.tsx' + +export function Page() { + return ( +
+

This is page "a"

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx new file mode 100644 index 000000000..0142ae85e --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx @@ -0,0 +1,7 @@ +'use server' + +import { getRequestContext } from '../request-context.ts' + +export async function actionB() { + return `ACTION_B_OK:${getRequestContext().middlewareTag}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx new file mode 100644 index 000000000..c020191f0 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx @@ -0,0 +1,23 @@ +'use client' + +import React from 'react' +import { getSavedAction, setSavedAction } from '../saved-action.ts' +import { actionB } from './action.tsx' + +export function ActionB() { + const [result, setResult] = React.useState('none') + const savedAction = getSavedAction() + return ( +
+ + + +

Result: {result}

+
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/app/b/middleware.ts new file mode 100644 index 000000000..672703f05 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/middleware.ts @@ -0,0 +1,5 @@ +import type { RouteMiddleware } from '../../framework/middleware.ts' +import { runWithRequestContext } from '../request-context.ts' + +export const middleware: RouteMiddleware = (_request, next) => + runWithRequestContext({ middlewareTag: 'MIDDLEWARE_B' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx new file mode 100644 index 000000000..5469d352a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx @@ -0,0 +1,10 @@ +import { ActionB } from './client.tsx' + +export function Page() { + return ( +
+

This is page "b"

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/request-context.ts b/packages/plugin-rsc/examples/action-reachability/src/app/request-context.ts new file mode 100644 index 000000000..82ebda26a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/request-context.ts @@ -0,0 +1,20 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +type RequestContext = { + middlewareTag: string +} + +const requestContextStorage = new AsyncLocalStorage() + +export function runWithRequestContext( + context: RequestContext, + callback: () => T, +): T { + return requestContextStorage.run(context, callback) +} + +export function getRequestContext(): RequestContext { + const context = requestContextStorage.getStore() + if (!context) throw new Error('Request context is not available') + return context +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/root.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/root.tsx new file mode 100644 index 000000000..22ad28479 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/root.tsx @@ -0,0 +1,20 @@ +export function Root(props: { children?: React.ReactNode }) { + return ( + + +

Save action A on /a, navigate to /b, then run the saved action.

+ + {props.children} + + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/routes.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/routes.tsx new file mode 100644 index 000000000..16195516c --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/routes.tsx @@ -0,0 +1,29 @@ +import type { RouteMiddleware } from '../framework/middleware.ts' +import { middleware as middlewareA } from './a/middleware.ts' +import { Page as PageA } from './a/page.tsx' +import { middleware as middlewareB } from './b/middleware.ts' +import { Page as PageB } from './b/page.tsx' +import { Root } from './root.tsx' + +// TODO: A framework would generate this registry from its route convention. +// This example declares it explicitly for simplicity. +export const routes = { + '/a': { Page: PageA, middleware: middlewareA }, + '/b': { Page: PageB, middleware: middlewareB }, +} + +const rootRoute: { + Page?: React.ComponentType + middleware: RouteMiddleware +} = { + middleware: (_request, next) => next(), +} + +export function getRoute(pathname: string) { + return routes[pathname as keyof typeof routes] ?? rootRoute +} + +export function RouteRoot(props: { pathname: string }) { + const { Page } = getRoute(props.pathname) + return {Page && } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/saved-action.ts b/packages/plugin-rsc/examples/action-reachability/src/app/saved-action.ts new file mode 100644 index 000000000..efec25af5 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/saved-action.ts @@ -0,0 +1,11 @@ +export type Action = () => Promise + +let savedAction: Action | undefined + +export function setSavedAction(action: Action) { + savedAction = action +} + +export function getSavedAction() { + return savedAction +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts new file mode 100644 index 000000000..467de5fbd --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts @@ -0,0 +1,48 @@ +import routeActionManifest from 'virtual:route-action-manifest' +import { createActionRoutingRequest, type RenderRequest } from './request.tsx' + +type ActionRoutingResult = + | { type: 'continue' } + | { type: 'redispatch'; request: Request } + | { type: 'reject'; response: Response } + +/** + * Routes an action through an application graph that can load it. Production + * uses the generated route manifest, while development stays on the current route. + */ +export function routeActionRequest( + renderRequest: RenderRequest, +): ActionRoutingResult { + const actionId = renderRequest.actionId + if (!actionId || !routeActionManifest) { + return { type: 'continue' } + } + + const pathname = Object.entries(routeActionManifest).find(([, actionIds]) => + actionIds.includes(actionId), + )?.[0] + if (!pathname) { + return { + type: 'reject', + response: new Response('Server action is not reachable from any route', { + status: 404, + }), + } + } + if (pathname === renderRequest.url.pathname) { + return { type: 'continue' } + } + if (renderRequest.isActionForwarded) { + return { + type: 'reject', + response: new Response( + 'Forwarded server action reached the wrong route', + { status: 404 }, + ), + } + } + return { + type: 'redispatch', + request: createActionRoutingRequest(renderRequest, pathname), + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx new file mode 100644 index 000000000..00dc9beef --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx @@ -0,0 +1,124 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromReadableStream(rscStream) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + 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 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 + }) + + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +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 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..9aa856981 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -0,0 +1,115 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { getRoute, RouteRoot } from '../app/routes.tsx' +import { routeActionRequest } from './action-routing.ts' +import { parseRenderRequest } from './request.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } + formState?: ReactFormState +} + +export default { fetch: handler } + +async function handler(request: Request): Promise { + const renderRequest = parseRenderRequest(request) + const { middleware } = getRoute(renderRequest.url.pathname) + // Redispatched actions re-enter route middleware before execution. + return middleware(renderRequest.request, () => handleRequest(renderRequest)) +} + +async function handleRequest( + renderRequest: ReturnType, +): Promise { + const request = renderRequest.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) { + // Route the action through an application graph that can load it. + const routing = routeActionRequest(renderRequest) + if (routing.type === 'reject') { + return routing.response + } + if (routing.type === 'redispatch') { + return handler(routing.request) + } + 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 { + // TODO: Resolve the submitted action ID from React's multipart fields + // and apply the same route-aware redispatch before decodeAction(). + // This example covers only the explicit-ID flow for simplicity. + // Next.js's pre-decode validation shows how these fields are inspected: + // https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576 + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..6eb6b3650 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx @@ -0,0 +1,63 @@ +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' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx new file mode 100644 index 000000000..1c7e047c1 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx @@ -0,0 +1,76 @@ +'use client' + +import React from 'react' + +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +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 + } +} + +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/action-reachability/src/framework/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts new file mode 100644 index 000000000..26105304a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts @@ -0,0 +1,4 @@ +export type RouteMiddleware = ( + request: Request, + next: () => Promise, +) => Promise diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx new file mode 100644 index 000000000..9df3392ea --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx @@ -0,0 +1,95 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' +const HEADER_ACTION_FORWARDED = 'x-action-forwarded' +const HEADER_RENDER_URL = 'x-rsc-render-url' + +/** Normalized request metadata used by RSC rendering and action routing. */ +export type RenderRequest = { + /** Whether the request targets the RSC transport endpoint. */ + isRsc: boolean + /** Whether the request invokes a server action. */ + isAction: boolean + /** 🚀 Action-reachability extension: whether routing already redispatched the action. */ + isActionForwarded: boolean + /** Explicit server action ID provided by the hydrated client. */ + actionId?: string + /** Request normalized to the application route URL. */ + request: Request + /** 🚀 Action-reachability extension: URL whose route should remain visible after redispatch. */ + renderUrl: URL + /** Application route URL used to execute the request. */ + url: URL +} + +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, { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function createActionRoutingRequest( + renderRequest: RenderRequest, + pathname: string, +): Request { + const body = renderRequest.request.body + if (!body) { + throw new Error('Missing action request body') + } + const targetUrl = new URL(renderRequest.request.url) + targetUrl.pathname = pathname + URL_POSTFIX + const headers = new Headers(renderRequest.request.headers) + headers.set(HEADER_ACTION_ID, renderRequest.actionId!) + headers.set(HEADER_ACTION_FORWARDED, '1') + headers.set(HEADER_RENDER_URL, renderRequest.renderUrl.href) + return new Request(targetUrl, { + method: 'POST', + headers, + body, + // @ts-ignore `duplex` is implemented by Node.js but missing from RequestInit. + duplex: 'half', + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + const isActionForwarded = request.headers.has(HEADER_ACTION_FORWARDED) + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const renderUrl = new URL(request.headers.get(HEADER_RENDER_URL) ?? url) + 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, + isActionForwarded, + actionId, + request: new Request(url, request), + renderUrl, + url, + } + } else { + const renderUrl = new URL(request.headers.get(HEADER_RENDER_URL) ?? url) + return { + isRsc: false, + isAction, + isActionForwarded, + request, + renderUrl, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts new file mode 100644 index 000000000..e4a8ce208 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts @@ -0,0 +1,4 @@ +declare module 'virtual:route-action-manifest' { + const manifest: Record | null + export default manifest +} diff --git a/packages/plugin-rsc/examples/action-reachability/tsconfig.json b/packages/plugin-rsc/examples/action-reachability/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/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/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts new file mode 100644 index 000000000..3c59ffd16 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -0,0 +1,37 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { routeActionManifestPlugin } from './route-action-manifest-plugin.ts' + +export default defineConfig({ + plugins: [rsc(), react(), routeActionManifestPlugin()], + 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', + }, + }, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index f1f6f65d8..b7374f77b 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -1,5 +1,6 @@ export { default, + type ReferenceReachabilityEntry, type RscPluginOptions, getPluginApi, type PluginApi, diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 965e7eaaf..1bd0e6c3f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -35,6 +35,10 @@ import { writeEnvironmentImportsManifest, type EnvironmentImportMeta, } from './plugins/import-environment' +import { + getClientToServerReferenceReachability, + type ReferenceReachabilityEntry, +} from './plugins/reference-reachability' import { vitePluginResolvedIdProxy, withResolvedIdProxy, @@ -98,6 +102,8 @@ type ClientReferenceMeta = { groupChunkId?: string } +export type { ReferenceReachabilityEntry } + const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -126,6 +132,23 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} + + /** + * Returns server references reachable from Client Component references + * through the current client module graph. + * + * Call this from a final client build hook while Rollup's module graph is + * available. See {@link ReferenceReachabilityEntry} for the reachability + * semantics. + * + * @experimental + */ + getClientToServerReferenceReachability( + context: Rollup.PluginContext, + ): ReferenceReachabilityEntry[] { + return getClientToServerReferenceReachability(context, this) + } + serverReferences: ServerReferencesManager = new ServerReferencesManager(this) /** @deprecated Use `serverReferences.metaMap` instead. */ diff --git a/packages/plugin-rsc/src/plugins/reference-reachability.ts b/packages/plugin-rsc/src/plugins/reference-reachability.ts new file mode 100644 index 000000000..4e83e3d27 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/reference-reachability.ts @@ -0,0 +1,58 @@ +import type { Rollup } from 'vite' +import type { RscPluginManager } from '../plugin' + +/** + * Server references reachable from a Client Component reference through the + * final client module graph. + * + * Reachability includes static and statically resolved dynamic imports. It is + * conservative at module granularity, so all server references exported by a + * reachable server-reference module are included. + * + * @experimental + */ +export type ReferenceReachabilityEntry = { + /** Resolved module ID used as the client graph traversal root. */ + importId: string + /** Reference key identifying the Client Component across environments. */ + referenceKey: string + /** Complete server-reference IDs in `referenceKey#exportName` form. */ + serverReferenceIds: string[] +} + +export function getClientToServerReferenceReachability( + context: Rollup.PluginContext, + manager: RscPluginManager, +): ReferenceReachabilityEntry[] { + const result: ReferenceReachabilityEntry[] = [] + for (const clientReference of Object.values(manager.clientReferenceMetaMap)) { + const serverReferenceIds = new Set() + const visited = new Set() + const queue = [clientReference.importId] + for (let index = 0; index < queue.length; index++) { + const id = queue[index]! + if (visited.has(id)) continue + visited.add(id) + + const serverReference = manager.serverReferences.metaMap.get(id) + if (serverReference) { + for (const exportName of serverReference.exportNames) { + serverReferenceIds.add( + `${serverReference.referenceKey}#${exportName}`, + ) + } + } + + const info = context.getModuleInfo(id) + if (!info) continue + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + + result.push({ + importId: clientReference.importId, + referenceKey: clientReference.referenceKey, + serverReferenceIds: [...serverReferenceIds].sort(), + }) + } + return result.sort((a, b) => a.importId.localeCompare(b.importId)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10761ebea..6f470a5eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,34 @@ importers: specifier: ^0.22.14 version: 0.22.14(publint@0.3.21)(typescript@6.0.3) + packages/plugin-rsc/examples/action-reachability: + 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.5 + version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/basic: dependencies: react: