Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d519286
feat(rsc): expose client action reachability
hi-ogawa Jul 28, 2026
6e152f3
Merge branch 'main' into opencode/rsc-cross-env-reachability
hi-ogawa Jul 28, 2026
690b928
refactor(rsc): expose action reachability as query
hi-ogawa Jul 28, 2026
19b3e4e
refactor(rsc): rename reference reachability type
hi-ogawa Jul 28, 2026
2ed2732
refactor(rsc): return reachability entries
hi-ogawa Jul 28, 2026
7479fc1
test(rsc): align reachability example with starter
hi-ogawa Jul 28, 2026
fe90836
test(rsc): demonstrate route-aware action dispatch
hi-ogawa Jul 28, 2026
3e275bf
docs(rsc): note progressive action follow-up
hi-ogawa Jul 28, 2026
9b6f8e4
Merge branch 'main' into opencode/rsc-cross-env-reachability
hi-ogawa Jul 29, 2026
0c6ff40
docs(rsc): focus reachability example comments
hi-ogawa Jul 29, 2026
d52b71b
refactor(rsc): align route manifest sidecar
hi-ogawa Jul 29, 2026
b1d4069
test(rsc): simplify route reachability fixture
hi-ogawa Jul 29, 2026
a137cca
docs(rsc): explain action reachability demo
hi-ogawa Jul 29, 2026
2601874
docs(rsc): clarify reachability demo pages
hi-ogawa Jul 29, 2026
245f677
test(rsc): retain action across navigation
hi-ogawa Jul 29, 2026
d6049ac
refactor(rsc): simplify retained action state
hi-ogawa Jul 29, 2026
3ae3bfd
docs(rsc): note action wrapper explanation
hi-ogawa Jul 29, 2026
9bd9795
docs(rsc): annotate action reachability scenario
hi-ogawa Jul 29, 2026
7f1d803
test(rsc): assert action dispatch through UI
hi-ogawa Jul 29, 2026
de5dad7
fix(rsc): bypass route manifest in dev
hi-ogawa Jul 29, 2026
e7f4ff3
test(rsc): keep reachability coverage behavioral
hi-ogawa Jul 29, 2026
91d1cbb
test(rsc): align reachability scenarios
hi-ogawa Jul 29, 2026
739d4e7
refactor(rsc): clarify indirect action value flow
hi-ogawa Jul 29, 2026
bf9df2a
refactor(rsc): use semantic example navigation
hi-ogawa Jul 29, 2026
c728f61
refactor(rsc): isolate action route resolution
hi-ogawa Jul 29, 2026
4b9eeab
refactor(rsc): isolate action request routing
hi-ogawa Jul 29, 2026
d5b0197
refactor(rsc): encapsulate action redispatch requests
hi-ogawa Jul 29, 2026
b422b1d
refactor(rsc): separate app route ownership
hi-ogawa Jul 29, 2026
79b0646
docs(rsc): clarify action routing flow
hi-ogawa Jul 29, 2026
f95dc97
refactor(rsc): clarify request middleware context
hi-ogawa Jul 29, 2026
ee0ef6c
docs(rsc): explain action route resolution
hi-ogawa Jul 29, 2026
4962494
docs(rsc): lead with action routing scenario
hi-ogawa Jul 29, 2026
3cc46c3
refactor(rsc): remove redundant reachability artifact
hi-ogawa Jul 29, 2026
59926da
docs(rsc): focus reachability example narrative
hi-ogawa Jul 29, 2026
1d28f7f
docs(rsc): mark reachability simplifications
hi-ogawa Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions packages/plugin-rsc/e2e/action-reachability.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
43 changes: 43 additions & 0 deletions packages/plugin-rsc/examples/action-reachability/README.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/action-reachability/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>()
const routeDirectServerReferenceIds = new Map<string, Set<string>>()
let routeActionManifest: Record<string, string[]> = {}

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<string>()
const directServerReferenceIds = new Set<string>()
const visited = new Set<string>()
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`,
)
},
},
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use server'

import { getRequestContext } from '../request-context.ts'

export async function actionA() {
return `ACTION_A_OK:${getRequestContext().middlewareTag}`
}
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => actionA().then(setResult)}>Run action A</button>
<button onClick={() => setSavedAction(actionA)}>Save action A</button>
<button
disabled={!savedAction}
onClick={() => savedAction?.().then(setResult)}
>
Run saved action
</button>
<p>Result: {result}</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ActionA } from './client.tsx'

export function Page() {
return (
<main>
<h1>This is page "a"</h1>
<ActionA />
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use server'

import { getRequestContext } from '../request-context.ts'

export async function actionB() {
return `ACTION_B_OK:${getRequestContext().middlewareTag}`
}
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => actionB().then(setResult)}>Run action B</button>
<button onClick={() => setSavedAction(actionB)}>Save action B</button>
<button
disabled={!savedAction}
onClick={() => savedAction?.().then(setResult)}
>
Run saved action
</button>
<p>Result: {result}</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ActionB } from './client.tsx'

export function Page() {
return (
<main>
<h1>This is page "b"</h1>
<ActionB />
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AsyncLocalStorage } from 'node:async_hooks'

type RequestContext = {
middlewareTag: string
}

const requestContextStorage = new AsyncLocalStorage<RequestContext>()

export function runWithRequestContext<T>(
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
}
Loading
Loading