From bde18b7d1a95bc3c3e3a443f392d161d0edbea0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:20:55 +0000 Subject: [PATCH 01/22] feat(plugin-rsc): add navigation example demonstrating coordinated history and transitions Add a new example that demonstrates how to properly coordinate browser history navigation with React transitions in RSC applications. Key features: - Dispatch-based navigation coordination pattern - History updates via useInsertionEffect after state updates - Promise-based navigation state with React.use() - Visual feedback with transition status indicator - Prevents race conditions with rapid navigation - Proper back/forward navigation support This pattern is inspired by Next.js App Router implementation and addresses common issues with client-side navigation in RSC apps: - URL bar staying in sync with rendered content - Proper loading state management - Race condition prevention - Coordinated back/forward navigation Based on: https://github.com/hi-ogawa/reproductions/tree/main/vite-rsc-coordinate-history-and-transition Related to: https://github.com/vitejs/vite-plugin-react/issues/860 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../plugin-rsc/examples/navigation/README.md | 99 +++++++ .../examples/navigation/package.json | 25 ++ .../examples/navigation/public/vite.svg | 1 + .../src/framework/entry.browser.tsx | 249 ++++++++++++++++++ .../navigation/src/framework/entry.rsc.tsx | 86 ++++++ .../navigation/src/framework/entry.ssr.tsx | 34 +++ .../navigation/src/framework/react.d.ts | 1 + .../examples/navigation/src/index.css | 249 ++++++++++++++++++ .../examples/navigation/src/root.tsx | 65 +++++ .../examples/navigation/src/routes/about.tsx | 35 +++ .../navigation/src/routes/counter-actions.tsx | 11 + .../navigation/src/routes/counter.tsx | 68 +++++ .../examples/navigation/src/routes/home.tsx | 78 ++++++ .../examples/navigation/src/routes/slow.tsx | 55 ++++ .../examples/navigation/tsconfig.json | 24 ++ .../examples/navigation/vite.config.ts | 22 ++ pnpm-lock.yaml | 40 ++- 17 files changed, 1135 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-rsc/examples/navigation/README.md create mode 100644 packages/plugin-rsc/examples/navigation/package.json create mode 100644 packages/plugin-rsc/examples/navigation/public/vite.svg create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/react.d.ts create mode 100644 packages/plugin-rsc/examples/navigation/src/index.css create mode 100644 packages/plugin-rsc/examples/navigation/src/root.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/routes/about.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/routes/counter.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/routes/home.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/routes/slow.tsx create mode 100644 packages/plugin-rsc/examples/navigation/tsconfig.json create mode 100644 packages/plugin-rsc/examples/navigation/vite.config.ts diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md new file mode 100644 index 000000000..def4649bd --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -0,0 +1,99 @@ +# Navigation Example - Coordinating History and Transitions + +This example demonstrates how to properly coordinate browser history navigation with React transitions in a React Server Components application. + +## Problem + +In a typical RSC application with client-side navigation, there's a challenge in coordinating: + +1. Browser history changes (pushState/replaceState/popstate) +2. React transitions for smooth updates +3. Asynchronous data fetching +4. Loading state indicators + +Without proper coordination, you can encounter: + +- URL bar being out of sync with rendered content +- Race conditions with rapid navigation +- Issues with back/forward navigation +- Missing or inconsistent loading indicators + +## Solution + +This example implements a pattern inspired by Next.js App Router that addresses these issues: + +### Key Concepts + +1. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions +2. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` +3. **useInsertionEffect**: History updates happen via `useInsertionEffect` to ensure they occur after state updates but before paint +4. **Transition Tracking**: Uses `useTransition` to track pending navigation state +5. **Visual Feedback**: Provides a pending indicator during navigation + +### Implementation + +The core implementation is in `src/framework/entry.browser.tsx`: + +```typescript +// Navigation state includes URL, push flag, and payload promise +type NavigationState = { + url: string + push?: boolean + payloadPromise: Promise +} + +// Dispatch coordinates navigation with transitions +dispatch = (action: NavigationAction) => { + startTransition(() => { + setState_({ + url: action.url, + push: action.push, + payloadPromise: action.payload + ? Promise.resolve(action.payload) + : createFromFetch(fetch(action.url)), + }) + }) +} + +// History updates happen via useInsertionEffect +function HistoryUpdater({ state }: { state: NavigationState }) { + React.useInsertionEffect(() => { + if (state.push) { + state.push = false + oldPushState.call(window.history, {}, '', state.url) + } + }, [state]) + return null +} +``` + +## Running the Example + +```bash +pnpm install +pnpm dev +``` + +Then navigate to http://localhost:5173 + +## What to Try + +1. **Basic Navigation**: Click between pages and notice the smooth transitions +2. **Slow Page**: Visit the "Slow Page" to see how loading states work with delays +3. **Rapid Navigation**: Click links rapidly to see that race conditions are prevented +4. **Back/Forward**: Use browser back/forward buttons to see proper coordination +5. **Counter Page**: See how client and server state interact with navigation + +## References + +This pattern is based on: + +- [Next.js App Router](https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx) +- [Next.js Action Queue](https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/use-action-queue.ts) +- [React useTransition](https://react.dev/reference/react/useTransition) +- [React.use](https://react.dev/reference/react/use) + +## Related + +- GitHub Issue: https://github.com/vitejs/vite-plugin-react/issues/860 +- Reproduction: https://github.com/hi-ogawa/reproductions/tree/main/vite-rsc-coordinate-history-and-transition diff --git a/packages/plugin-rsc/examples/navigation/package.json b/packages/plugin-rsc/examples/navigation/package.json new file mode 100644 index 000000000..3c33e3d0c --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -0,0 +1,25 @@ +{ + "name": "@vitejs/plugin-rsc-examples-navigation", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-rsc": "latest", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.0.5", + "vite-plugin-inspect": "^11.3.0" + } +} diff --git a/packages/plugin-rsc/examples/navigation/public/vite.svg b/packages/plugin-rsc/examples/navigation/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx new file mode 100644 index 000000000..379dc6553 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -0,0 +1,249 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} 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' + +/** + * This example demonstrates coordinating history navigation with React transitions. + * + * Key improvements over basic navigation: + * 1. Uses dispatch pattern to coordinate navigation actions + * 2. History updates happen via useInsertionEffect AFTER state updates + * 3. Navigation state includes payloadPromise, url, and push flag + * 4. React.use() unwraps the promise in render + * 5. Provides visual feedback with transition status + * + * Based on Next.js App Router implementation: + * https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx + */ + +let dispatch: (action: NavigationAction) => void + +async function main() { + // Deserialize initial RSC stream from SSR + const initialPayload = await createFromReadableStream(rscStream) + + const initialNavigationState: NavigationState = { + payloadPromise: Promise.resolve(initialPayload), + url: window.location.href, + push: false, + } + + // Browser root component that manages navigation state + function BrowserRoot() { + const [state, setState_] = React.useState(initialNavigationState) + const [isPending, startTransition] = React.useTransition() + + // Setup dispatch function that coordinates navigation with transitions + // Inspired by Next.js action queue pattern: + // https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/use-action-queue.ts + React.useEffect(() => { + dispatch = (action: NavigationAction) => { + startTransition(() => { + setState_({ + url: action.url, + push: action.push, + payloadPromise: action.payload + ? Promise.resolve(action.payload) + : createFromFetch(fetch(action.url)), + }) + }) + } + }, [setState_]) + + // Setup navigation listeners + React.useEffect(() => { + return listenNavigation() + }, []) + + return ( + <> + + + + + ) + } + + /** + * Visual indicator for pending transitions + */ + function TransitionStatus(props: { isPending: boolean }) { + React.useEffect(() => { + let el = document.querySelector('#pending') as HTMLDivElement + if (!el) { + el = document.createElement('div') + el.id = 'pending' + el.textContent = 'pending...' + el.style.position = 'fixed' + el.style.bottom = '10px' + el.style.right = '10px' + el.style.padding = '8px 16px' + el.style.backgroundColor = 'rgba(0, 0, 0, 0.8)' + el.style.color = 'white' + el.style.borderRadius = '4px' + el.style.fontSize = '14px' + el.style.fontFamily = 'monospace' + el.style.transition = 'opacity 0.3s ease-in-out' + el.style.pointerEvents = 'none' + el.style.zIndex = '9999' + document.body.appendChild(el) + } + if (props.isPending) { + el.style.opacity = '1' + } else { + el.style.opacity = '0' + } + }, [props.isPending]) + return null + } + + /** + * Renders the current navigation state + * Uses React.use() to unwrap the payload promise + */ + function RenderState({ state }: { state: NavigationState }) { + const payload = React.use(state.payloadPromise) + return payload.root + } + + // Register server callback for server actions + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + dispatch({ url: url.href, payload }) + return payload.returnValue + }) + + // Hydrate root + const browserRoot = ( + + + + ) + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + + // HMR support + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + dispatch({ url: window.location.href }) + }) + } +} + +/** + * Navigation state shape + */ +type NavigationState = { + url: string + push?: boolean + payloadPromise: Promise +} + +/** + * Navigation action shape + */ +type NavigationAction = { + url: string + push?: boolean + payload?: RscPayload +} + +// Save reference to original pushState +const oldPushState = window.history.pushState + +/** + * Component that updates browser history via useInsertionEffect + * This ensures history updates happen AFTER the state update but BEFORE paint + * Inspired by Next.js App Router: + * https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx + */ +function HistoryUpdater({ state }: { state: NavigationState }) { + React.useInsertionEffect(() => { + if (state.push) { + state.push = false + oldPushState.call(window.history, {}, '', state.url) + } + }, [state]) + + return null +} + +/** + * Setup navigation interception + */ +function listenNavigation() { + // Intercept pushState + window.history.pushState = function (...args) { + const url = new URL(args[2] || window.location.href, window.location.href) + dispatch({ url: url.href, push: true }) + return + } + + // Intercept replaceState + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const url = new URL(args[2] || window.location.href, window.location.href) + dispatch({ url: url.href }) + return + } + + // Handle back/forward navigation + function onPopstate() { + const href = window.location.href + dispatch({ url: href }) + } + window.addEventListener('popstate', onPopstate) + + // Intercept link clicks + function onClick(e: MouseEvent) { + const 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) + } + } + document.addEventListener('click', onClick) + + // Cleanup + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onPopstate) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..b1d5e2658 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -0,0 +1,86 @@ +import * as ReactServer from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +export default async function handler(request: Request): Promise { + // Handle server action requests + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = ReactServer.createTemporaryReferenceSet() + const args = await ReactServer.decodeReply(body, { temporaryReferences }) + const action = await ReactServer.loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + const formData = await request.formData() + const decodedAction = await ReactServer.decodeAction(formData) + const result = await decodedAction() + formState = await ReactServer.decodeFormState(result, formData) + } + } + + // Parse URL to pass to Root component + const url = new URL(request.url) + + // Render RSC payload + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = ReactServer.renderToReadableStream( + rscPayload, + rscOptions, + ) + + // Determine if this is an RSC request or HTML request + const isRscRequest = + (!request.headers.get('accept')?.includes('text/html') && + !url.searchParams.has('__html')) || + url.searchParams.has('__rsc') + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + // Delegate to SSR for HTML rendering + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const htmlStream = await ssrEntryModule.renderHTML(rscStream, { + formState, + debugNojs: url.searchParams.has('__nojs'), + }) + + return new Response(htmlStream, { + headers: { + 'Content-type': 'text/html', + vary: 'accept', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..20b1ecf41 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -0,0 +1,34 @@ +import * as ReactServer from '@vitejs/plugin-rsc/rsc' +import * as ReactDOMServer from 'react-dom/server' +import type { ReactFormState } from 'react-dom/client' +import { injectRscStreamToHtml } from 'rsc-html-stream/server' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + debugNojs?: boolean + }, +): Promise { + const [rscStream1, rscStream2] = rscStream.tee() + + // Deserialize RSC stream to React elements for SSR + const root = await ReactServer.createFromNodeStream( + rscStream1, + {}, + { clientManifest: import.meta.viteRsc.clientManifest }, + ) + + // Render to HTML stream + const htmlStream = await ReactDOMServer.renderToReadableStream(root, { + formState: options.formState, + bootstrapModules: options.debugNojs + ? [] + : [import.meta.viteRsc.clientManifest.entryModule], + }) + + // Inject RSC stream into HTML for client hydration + const mergedStream = injectRscStreamToHtml(htmlStream, rscStream2) + + return mergedStream +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts b/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts new file mode 100644 index 000000000..af5a1ad3e --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugin-rsc/examples/navigation/src/index.css b/packages/plugin-rsc/examples/navigation/src/index.css new file mode 100644 index 000000000..de3530235 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -0,0 +1,249 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + min-height: 100vh; +} + +.app { + max-width: 1200px; + margin: 0 auto; +} + +.nav { + background: rgba(255, 255, 255, 0.05); + padding: 1rem 2rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.nav h2 { + margin: 0; + font-size: 1.5rem; + color: #646cff; +} + +.nav-links { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.nav-links a { + padding: 0.5rem 1rem; + text-decoration: none; + color: rgba(255, 255, 255, 0.87); + border-radius: 4px; + transition: all 0.2s; + border: 1px solid transparent; +} + +.nav-links a:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.nav-links a.active { + background: #646cff; + color: white; +} + +.main { + padding: 2rem; +} + +.page { + max-width: 800px; +} + +.page h1 { + font-size: 2.5rem; + margin-top: 0; + margin-bottom: 1rem; + color: #646cff; +} + +.page > p { + font-size: 1.1rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); +} + +.card { + background: rgba(255, 255, 255, 0.05); + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.card h2 { + margin-top: 0; + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.9); +} + +.card p { + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); +} + +.card ul, +.card ol { + line-height: 1.8; + color: rgba(255, 255, 255, 0.7); +} + +.card li { + margin-bottom: 0.5rem; +} + +.card li strong { + color: rgba(255, 255, 255, 0.9); +} + +.card code { + background: rgba(0, 0, 0, 0.3); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: #646cff; +} + +.code-ref { + font-size: 0.9rem; + font-style: italic; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.button-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +button, +.button { + padding: 0.6rem 1.2rem; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + background-color: #646cff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-block; +} + +button:hover, +.button:hover { + background-color: #535bf2; + transform: translateY(-1px); +} + +button:active, +.button:active { + transform: translateY(0); +} + +.note { + font-size: 0.9rem; + font-style: italic; + color: rgba(255, 255, 255, 0.5); + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +form { + display: inline; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + .nav { + background: rgba(0, 0, 0, 0.03); + border-bottom-color: rgba(0, 0, 0, 0.1); + } + + .nav-links a { + color: #213547; + } + + .nav-links a:hover { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.1); + } + + .nav-links a.active { + color: white; + } + + .page > p { + color: rgba(0, 0, 0, 0.6); + } + + .card { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.1); + } + + .card h2 { + color: #213547; + } + + .card p, + .card ul, + .card ol { + color: rgba(0, 0, 0, 0.7); + } + + .card li strong { + color: #213547; + } + + .card code { + background: rgba(0, 0, 0, 0.1); + } + + .code-ref { + border-top-color: rgba(0, 0, 0, 0.1); + } + + .note { + color: rgba(0, 0, 0, 0.5); + border-top-color: rgba(0, 0, 0, 0.1); + } +} diff --git a/packages/plugin-rsc/examples/navigation/src/root.tsx b/packages/plugin-rsc/examples/navigation/src/root.tsx new file mode 100644 index 000000000..b74c1ac6e --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/root.tsx @@ -0,0 +1,65 @@ +import './index.css' +import { HomePage } from './routes/home' +import { AboutPage } from './routes/about' +import { SlowPage } from './routes/slow' +import { CounterPage } from './routes/counter' + +export function Root(props: { url: URL }) { + const pathname = props.url.pathname + + let page: React.ReactNode + let title = 'Navigation Example' + + if (pathname === '/about') { + page = + title = 'About - Navigation Example' + } else if (pathname === '/slow') { + page = + title = 'Slow Page - Navigation Example' + } else if (pathname === '/counter') { + page = + title = 'Counter - Navigation Example' + } else { + page = + title = 'Home - Navigation Example' + } + + return ( + + + + + + {title} + + +
+ +
{page}
+
+ + + ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/about.tsx b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx new file mode 100644 index 000000000..baf6a196d --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx @@ -0,0 +1,35 @@ +export function AboutPage() { + return ( +
+

About

+

+ This is a React Server Component rendered on the server and streamed to + the client. +

+
+

Navigation Coordination

+

+ When you navigate between pages, the navigation is coordinated with + React transitions to ensure: +

+
    +
  1. The URL updates at the right time
  2. +
  3. Loading states are properly displayed
  4. +
  5. Race conditions are prevented
  6. +
  7. Back/forward navigation works correctly
  8. +
+
+
+

Current Time

+

+ This page was rendered on the server at:{' '} + {new Date().toLocaleTimeString()} +

+

+ Navigate away and back to see the time update, demonstrating that the + page is re-rendered on the server each time. +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx b/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx new file mode 100644 index 000000000..d93cb03a0 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function incrementServerCounter() { + serverCounter++ +} + +export function getServerCounter() { + return serverCounter +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx b/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx new file mode 100644 index 000000000..e6c5d7e1d --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' +import { incrementServerCounter, getServerCounter } from './counter-actions' + +/** + * This page demonstrates navigation with both client and server state. + */ +export function CounterPage() { + const [clientCount, setClientCount] = useState(0) + + return ( +
+

Counter Page

+

+ This page demonstrates client and server state management with + coordinated navigation. +

+
+

Client Counter

+

Current count: {clientCount}

+
+ + +
+

+ This counter is managed on the client. Notice that it resets when you + navigate away and back. +

+
+
+

Server Counter

+ +

+ This counter is managed on the server. It persists across navigations + because it's part of the server state. +

+
+
+

Try this:

+
    +
  1. Increment both counters
  2. +
  3. Navigate to another page
  4. +
  5. Navigate back to this page
  6. +
  7. + Notice that the client counter resets but the server counter + persists +
  8. +
+
+
+ ) +} + +function ServerCounter() { + const count = getServerCounter() + + return ( + <> +

Current count: {count}

+
+ +
+ + ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx new file mode 100644 index 000000000..6c611e009 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -0,0 +1,78 @@ +export function HomePage() { + return ( +
+

Home Page

+

+ This example demonstrates coordinating browser history navigation with + React transitions. +

+
+

Key Features

+
    +
  • + Coordinated Updates: History updates happen via{' '} + useInsertionEffect after state updates but before paint +
  • +
  • + Transition Tracking: Uses{' '} + useTransition to track navigation state +
  • +
  • + Promise-based State: Navigation state includes a{' '} + payloadPromise unwrapped with React.use() +
  • +
  • + Visual Feedback: A pending indicator appears during + navigation +
  • +
  • + Race Condition Prevention: Proper coordination + prevents issues with rapid navigation +
  • +
+
+
+

Try it out

+

+ Click the navigation links above to see the coordinated navigation in + action: +

+
    +
  • + About - A regular page +
  • +
  • + Slow Page - Simulates a slow server response +
  • +
  • + Counter - A page with server and client state +
  • +
+

+ Notice the "pending..." indicator in the bottom right during + navigation. Try clicking links rapidly or using the browser + back/forward buttons. +

+
+
+

Implementation Details

+

+ This pattern is inspired by Next.js App Router and addresses common + issues with client-side navigation in React Server Components: +

+
    +
  • + The URL bar and rendered content stay in sync during transitions +
  • +
  • Back/forward navigation properly coordinates with React
  • +
  • Multiple rapid navigations don't cause race conditions
  • +
  • Loading states are properly managed
  • +
+

+ See src/framework/entry.browser.tsx for the + implementation. +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx new file mode 100644 index 000000000..ab47450d8 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx @@ -0,0 +1,55 @@ +/** + * This page simulates a slow server response to demonstrate + * the navigation transition coordination. + */ +export async function SlowPage(props: { url: URL }) { + const delay = Number(props.url.searchParams.get('delay')) || 2000 + + // Simulate slow server response + await new Promise((resolve) => setTimeout(resolve, delay)) + + return ( +
+

Slow Page

+

+ This page simulates a slow server response (delay: {delay}ms) to + demonstrate the navigation transition coordination. +

+
+

What to notice:

+
    +
  • The "pending..." indicator appears while the page is loading
  • +
  • The URL updates immediately when the transition starts
  • +
  • The page content doesn't change until the new data is ready
  • +
  • + If you click another link while this is loading, the navigation is + properly coordinated +
  • +
+
+
+

Try different delays:

+ +
+
+

Page loaded at:

+

+ {new Date().toLocaleTimeString()} +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/tsconfig.json b/packages/plugin-rsc/examples/navigation/tsconfig.json new file mode 100644 index 000000000..6d545f543 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/plugin-rsc/examples/navigation/vite.config.ts b/packages/plugin-rsc/examples/navigation/vite.config.ts new file mode 100644 index 000000000..9a0d19565 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/vite.config.ts @@ -0,0 +1,22 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import inspect from 'vite-plugin-inspect' + +export default defineConfig({ + clearScreen: false, + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + }, + }), + !process.env.ECOSYSTEM_CI && inspect(), + ], + build: { + minify: false, + }, +}) as any diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed03505b5..f48560f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,6 +573,37 @@ importers: specifier: 19.1.0-rc.2 version: 19.1.0-rc.2 + packages/plugin-rsc/examples/navigation: + dependencies: + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) + devDependencies: + '@types/react': + specifier: ^19.1.8 + version: 19.1.8 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.1.6(@types/react@19.1.8) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^7.0.5 + version: 7.0.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1) + vite-plugin-inspect: + specifier: ^11.3.0 + version: 11.3.0(vite@7.0.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1)) + packages/plugin-rsc/examples/no-ssr: dependencies: '@vitejs/plugin-rsc': @@ -3231,9 +3262,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - birpc@2.4.0: - resolution: {integrity: sha512-5IdNxTyhXHv2UlgnPHQ0h+5ypVmkrYHzL8QT+DwFZ//2N/oNV8Ch+BCRmTJ3x6/z9Axo/cXYBc9eprsUVK/Jsg==} - birpc@2.5.0: resolution: {integrity: sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==} @@ -7785,8 +7813,6 @@ snapshots: balanced-match@1.0.2: {} - birpc@2.4.0: {} - birpc@2.5.0: {} blake3-wasm@2.1.5: {} @@ -8508,7 +8534,7 @@ snapshots: dependencies: magic-string: 0.30.17 mlly: 1.7.4 - rollup: 4.37.0 + rollup: 4.44.1 flat-cache@4.0.1: dependencies: @@ -10472,7 +10498,7 @@ snapshots: vite-dev-rpc@1.1.0(vite@7.0.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1)): dependencies: - birpc: 2.4.0 + birpc: 2.5.0 vite: 7.0.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1) vite-hot-client: 2.1.0(vite@7.0.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1)) From 55d26f2b06a0249a35056808bd8dd89a250f6013 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:43:57 +0000 Subject: [PATCH 02/22] feat(plugin-rsc): add back/forward cache to navigation example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement instant back/forward navigation using history-state-keyed cache: - Cache maps history.state.key → Promise - Cache hit: synchronous render, no loading state - Cache miss: async fetch, shows transition - Server actions update cache for current entry - Each history entry gets unique random key This pattern enables: - Instant back/forward navigation (no server fetch) - Proper cache invalidation after mutations - Browser-native scroll restoration - Loading states only for actual fetches Based on: https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server/src/features/router/browser.ts Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../plugin-rsc/examples/navigation/README.md | 101 +++++++++----- .../src/framework/entry.browser.tsx | 130 ++++++++++++++---- .../examples/navigation/src/routes/home.tsx | 77 ++++++++--- 3 files changed, 235 insertions(+), 73 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md index def4649bd..334435660 100644 --- a/packages/plugin-rsc/examples/navigation/README.md +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -1,6 +1,6 @@ -# Navigation Example - Coordinating History and Transitions +# Navigation Example - Coordinating History, Transitions, and Caching -This example demonstrates how to properly coordinate browser history navigation with React transitions in a React Server Components application. +This example demonstrates how to properly coordinate browser history navigation with React transitions and implement instant back/forward navigation via caching in a React Server Components application. ## Problem @@ -10,39 +10,58 @@ In a typical RSC application with client-side navigation, there's a challenge in 2. React transitions for smooth updates 3. Asynchronous data fetching 4. Loading state indicators +5. Back/forward navigation performance Without proper coordination, you can encounter: - URL bar being out of sync with rendered content -- Race conditions with rapid navigation -- Issues with back/forward navigation +- Slow back/forward navigation (refetching from server) +- Issues with cache invalidation after mutations - Missing or inconsistent loading indicators ## Solution -This example implements a pattern inspired by Next.js App Router that addresses these issues: +This example implements a caching pattern that addresses these issues: ### Key Concepts -1. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions -2. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` -3. **useInsertionEffect**: History updates happen via `useInsertionEffect` to ensure they occur after state updates but before paint -4. **Transition Tracking**: Uses `useTransition` to track pending navigation state -5. **Visual Feedback**: Provides a pending indicator during navigation +1. **Back/Forward Cache by History Entry**: Each history entry gets a unique key, cache maps `key → Promise` +2. **Instant Navigation**: Cache hits render synchronously (no loading state), cache misses show transitions +3. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions +4. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` +5. **useInsertionEffect**: History updates happen via `useInsertionEffect` to ensure they occur after state updates but before paint +6. **Cache Invalidation**: Server actions update cache for current entry ### Implementation The core implementation is in `src/framework/entry.browser.tsx`: ```typescript -// Navigation state includes URL, push flag, and payload promise -type NavigationState = { - url: string - push?: boolean - payloadPromise: Promise +// Back/Forward cache keyed by history state +class BackForwardCache { + private cache: Record = {} + + run(fn: () => T): T { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) // Cache hit returns immediately! + } + return fn() + } + + set(value: T | undefined) { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } + } } -// Dispatch coordinates navigation with transitions +// Dispatch coordinates navigation with transitions and cache dispatch = (action: NavigationAction) => { startTransition(() => { setState_({ @@ -50,23 +69,25 @@ dispatch = (action: NavigationAction) => { push: action.push, payloadPromise: action.payload ? Promise.resolve(action.payload) - : createFromFetch(fetch(action.url)), + : bfCache.run(() => createFromFetch(fetch(action.url))), }) }) } -// History updates happen via useInsertionEffect -function HistoryUpdater({ state }: { state: NavigationState }) { - React.useInsertionEffect(() => { - if (state.push) { - state.push = false - oldPushState.call(window.history, {}, '', state.url) - } - }, [state]) - return null +// Each history entry gets a unique key +function addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } } ``` +**Why this works:** + +- `React.use()` can unwrap both promises AND resolved values +- Cache hit → returns existing promise → `React.use()` unwraps synchronously → instant render, no transition! +- Cache miss → creates new fetch promise → `React.use()` suspends → shows loading, transition active +- Browser automatically handles scroll restoration via proper history state + ## Running the Example ```bash @@ -78,18 +99,30 @@ Then navigate to http://localhost:5173 ## What to Try -1. **Basic Navigation**: Click between pages and notice the smooth transitions -2. **Slow Page**: Visit the "Slow Page" to see how loading states work with delays -3. **Rapid Navigation**: Click links rapidly to see that race conditions are prevented -4. **Back/Forward**: Use browser back/forward buttons to see proper coordination -5. **Counter Page**: See how client and server state interact with navigation +1. **Cache Behavior**: + - Visit "Slow Page" (notice the loading indicator) + - Navigate to another page + - Click browser back button + - Notice: No loading indicator! Instant render from cache + +2. **Cache Miss vs Hit**: + - First visit to any page shows "loading..." (cache miss) + - Back/forward to visited pages is instant (cache hit) + - Even slow pages are instant on second visit + +3. **Server Actions**: + - Go to "Counter Page" and increment server counter + - Notice the cache updates for current entry + - Navigate away and back to see updated state + +4. **Scroll Restoration**: Browser handles this automatically via proper history state ## References -This pattern is based on: +This pattern is inspired by: -- [Next.js App Router](https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx) -- [Next.js Action Queue](https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/use-action-queue.ts) +- [hi-ogawa/vite-environment-examples](https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server/src/features/router/browser.ts) - Back/forward cache implementation +- [TanStack Router](https://github.com/TanStack/router/blob/main/packages/history/src/index.ts) - History state key pattern - [React useTransition](https://react.dev/reference/react/useTransition) - [React.use](https://react.dev/reference/react/use) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index 379dc6553..b5930dec7 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -11,17 +11,17 @@ import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' /** - * This example demonstrates coordinating history navigation with React transitions. + * This example demonstrates coordinating history navigation with React transitions + * and caching RSC payloads by history entry. * - * Key improvements over basic navigation: - * 1. Uses dispatch pattern to coordinate navigation actions - * 2. History updates happen via useInsertionEffect AFTER state updates - * 3. Navigation state includes payloadPromise, url, and push flag - * 4. React.use() unwraps the promise in render - * 5. Provides visual feedback with transition status + * Key features: + * 1. Back/forward navigation is instant via cache (no loading state) + * 2. Cache is keyed by history state, not URL + * 3. Server actions invalidate cache for current entry + * 4. Proper coordination of history updates with transitions * - * Based on Next.js App Router implementation: - * https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx + * Pattern inspired by: + * https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server */ let dispatch: (action: NavigationAction) => void @@ -30,6 +30,9 @@ async function main() { // Deserialize initial RSC stream from SSR const initialPayload = await createFromReadableStream(rscStream) + // Initialize back/forward cache + const bfCache = new BackForwardCache>() + const initialNavigationState: NavigationState = { payloadPromise: Promise.resolve(initialPayload), url: window.location.href, @@ -42,8 +45,6 @@ async function main() { const [isPending, startTransition] = React.useTransition() // Setup dispatch function that coordinates navigation with transitions - // Inspired by Next.js action queue pattern: - // https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/use-action-queue.ts React.useEffect(() => { dispatch = (action: NavigationAction) => { startTransition(() => { @@ -52,7 +53,11 @@ async function main() { push: action.push, payloadPromise: action.payload ? Promise.resolve(action.payload) - : createFromFetch(fetch(action.url)), + : // Use cache: if cached, returns immediately (sync render!) + // if not cached, creates fetch and caches it + bfCache.run(() => + createFromFetch(fetch(action.url)), + ), }) }) } @@ -74,6 +79,7 @@ async function main() { /** * Visual indicator for pending transitions + * Only shows when actually fetching (cache miss) */ function TransitionStatus(props: { isPending: boolean }) { React.useEffect(() => { @@ -81,7 +87,6 @@ async function main() { if (!el) { el = document.createElement('div') el.id = 'pending' - el.textContent = 'pending...' el.style.position = 'fixed' el.style.bottom = '10px' el.style.right = '10px' @@ -96,7 +101,9 @@ async function main() { el.style.zIndex = '9999' document.body.appendChild(el) } + if (props.isPending) { + el.textContent = 'loading...' el.style.opacity = '1' } else { el.style.opacity = '0' @@ -128,6 +135,9 @@ async function main() { }), { temporaryReferences }, ) + const payloadPromise = Promise.resolve(payload) + // Update cache for current history entry + bfCache.set(payloadPromise) dispatch({ url: url.href, payload }) return payload.returnValue }) @@ -145,6 +155,8 @@ async function main() { // HMR support if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { + // Invalidate cache for current entry on HMR + bfCache.set(undefined) dispatch({ url: window.location.href }) }) } @@ -168,20 +180,86 @@ type NavigationAction = { payload?: RscPayload } -// Save reference to original pushState +/** + * History state with unique key per entry + */ +type HistoryState = null | { + key?: string +} + +// Save reference to original history methods const oldPushState = window.history.pushState +const oldReplaceState = window.history.replaceState + +/** + * Back/Forward cache keyed by history state + * + * Each history entry gets a unique random key stored in history.state. + * Cache maps key → value, enabling instant back/forward navigation. + */ +class BackForwardCache { + private cache: Record = {} + + /** + * Get cached value or run function to create it + * If current history state has a key and it's cached, return cached value. + * Otherwise run function, cache result, and return it. + */ + run(fn: () => T): T { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) + } + return fn() + } + + /** + * Set value for current history entry + * Used to update cache after server actions or to invalidate (set undefined) + */ + set(value: T | undefined) { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } + } +} + +/** + * Initialize history state with unique key if not present + */ +function initStateKey() { + if (!(window.history.state as HistoryState)?.key) { + oldReplaceState.call( + window.history, + addStateKey(window.history.state), + '', + window.location.href, + ) + } +} + +/** + * Add unique key to history state + */ +function addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } +} /** * Component that updates browser history via useInsertionEffect * This ensures history updates happen AFTER the state update but BEFORE paint - * Inspired by Next.js App Router: - * https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/app-router.tsx */ function HistoryUpdater({ state }: { state: NavigationState }) { React.useInsertionEffect(() => { if (state.push) { state.push = false - oldPushState.call(window.history, {}, '', state.url) + oldPushState.call(window.history, addStateKey({}), '', state.url) } }, [state]) @@ -189,22 +267,28 @@ function HistoryUpdater({ state }: { state: NavigationState }) { } /** - * Setup navigation interception + * Setup navigation interception with history state keys */ function listenNavigation() { + // Initialize current history state with key + initStateKey() + // Intercept pushState window.history.pushState = function (...args) { + args[0] = addStateKey(args[0]) + const res = oldPushState.apply(this, args) const url = new URL(args[2] || window.location.href, window.location.href) - dispatch({ url: url.href, push: true }) - return + dispatch({ url: url.href, push: false }) // push already happened above + return res } // Intercept replaceState - const oldReplaceState = window.history.replaceState window.history.replaceState = function (...args) { + args[0] = addStateKey(args[0]) + const res = oldReplaceState.apply(this, args) const url = new URL(args[2] || window.location.href, window.location.href) dispatch({ url: url.href }) - return + return res } // Handle back/forward navigation @@ -232,7 +316,7 @@ function listenNavigation() { !e.defaultPrevented ) { e.preventDefault() - history.pushState(null, '', link.href) + history.pushState({}, '', link.href) } } document.addEventListener('click', onClick) diff --git a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx index 6c611e009..f95a4df98 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -4,30 +4,35 @@ export function HomePage() {

Home Page

This example demonstrates coordinating browser history navigation with - React transitions. + React transitions and caching RSC payloads by history entry.

Key Features

    +
  • + Instant Back/Forward: Cache keyed by history state + means back/forward navigation is instant with no loading state +
  • Coordinated Updates: History updates happen via{' '} useInsertionEffect after state updates but before paint
  • +
  • + Smart Caching: Each history entry gets a unique + key, cache is per-entry not per-URL +
  • Transition Tracking: Uses{' '} - useTransition to track navigation state + useTransition to track navigation state (only for cache + misses)
  • Promise-based State: Navigation state includes a{' '} payloadPromise unwrapped with React.use()
  • - Visual Feedback: A pending indicator appears during - navigation -
  • -
  • - Race Condition Prevention: Proper coordination - prevents issues with rapid navigation + Cache Invalidation: Server actions update cache for + current entry
@@ -49,24 +54,64 @@ export function HomePage() {

- Notice the "pending..." indicator in the bottom right during - navigation. Try clicking links rapidly or using the browser - back/forward buttons. + Notice the cache behavior: +

+
    +
  • + First visit to a page shows "loading..." indicator (cache miss) +
  • +
  • Navigate to another page, then use browser back button
  • +
  • + No loading indicator! The page renders instantly from cache (cache + hit) +
  • +
  • + Even the slow page is instant on back/forward after first visit +
  • +
+ +
+

How the Cache Works

+

The cache is keyed by history entry, not URL:

+
    +
  1. + Each history.state gets a unique random{' '} + key +
  2. +
  3. + Cache maps key → Promise<RscPayload> +
  4. +
  5. On navigation, check if current history state key is in cache
  6. +
  7. + Cache hit → return existing promise → React.use(){' '} + unwraps synchronously → instant render! +
  8. +
  9. + Cache miss → fetch from server → shows loading state → cache result +
  10. +
+

+ This means visiting the same URL at different times creates different + cache entries. Perfect for back/forward navigation!

Implementation Details

- This pattern is inspired by Next.js App Router and addresses common - issues with client-side navigation in React Server Components: + This pattern addresses common issues with client-side navigation in + React Server Components:

  • The URL bar and rendered content stay in sync during transitions
  • -
  • Back/forward navigation properly coordinates with React
  • -
  • Multiple rapid navigations don't cause race conditions
  • -
  • Loading states are properly managed
  • +
  • + Back/forward navigation is instant via cache (no unnecessary + fetches) +
  • +
  • Server actions invalidate cache for current entry
  • +
  • Browser handles scroll restoration automatically
  • +
  • Loading states only show for actual fetches (cache misses)

See src/framework/entry.browser.tsx for the From ec8e16eece61a801239f19ed42b4599ea7427d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:50:00 +0000 Subject: [PATCH 03/22] refactor(plugin-rsc): consolidate navigation logic into Router class Consolidate all navigation logic into a single Router class for better organization and maintainability. Before: Logic was fragmented across module-level variables (dispatch, bfCache), standalone functions (listenNavigation, addStateKey), and separate components (HistoryUpdater). After: Single Router class encapsulates: - Navigation state management - Back/forward cache - History interception (pushState/replaceState/popstate) - Link click handling - React integration via setReactHandlers() API: - new Router(initialPayload) - create instance - router.setReactHandlers(setState, startTransition) - connect to React - router.listen() - setup listeners, returns cleanup - router.navigate(url, push) - navigate to URL - router.handleServerAction(payload) - handle server action - router.invalidateCache() - invalidate cache - router.commitHistoryPush(url) - commit push (useInsertionEffect) Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/framework/entry.browser.tsx | 368 ++++++++++-------- 1 file changed, 196 insertions(+), 172 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index b5930dec7..ee3b01f44 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -18,68 +18,51 @@ import type { RscPayload } from './entry.rsc' * 1. Back/forward navigation is instant via cache (no loading state) * 2. Cache is keyed by history state, not URL * 3. Server actions invalidate cache for current entry - * 4. Proper coordination of history updates with transitions + * 4. All navigation logic consolidated in Router class * * Pattern inspired by: * https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server */ -let dispatch: (action: NavigationAction) => void - async function main() { // Deserialize initial RSC stream from SSR const initialPayload = await createFromReadableStream(rscStream) - // Initialize back/forward cache - const bfCache = new BackForwardCache>() - - const initialNavigationState: NavigationState = { - payloadPromise: Promise.resolve(initialPayload), - url: window.location.href, - push: false, - } + // Create router instance + const router = new Router(initialPayload) - // Browser root component that manages navigation state + // Browser root component function BrowserRoot() { - const [state, setState_] = React.useState(initialNavigationState) + const [state, setState] = React.useState(router.getState()) const [isPending, startTransition] = React.useTransition() - // Setup dispatch function that coordinates navigation with transitions + // Connect router to React state React.useEffect(() => { - dispatch = (action: NavigationAction) => { - startTransition(() => { - setState_({ - url: action.url, - push: action.push, - payloadPromise: action.payload - ? Promise.resolve(action.payload) - : // Use cache: if cached, returns immediately (sync render!) - // if not cached, creates fetch and caches it - bfCache.run(() => - createFromFetch(fetch(action.url)), - ), - }) - }) - } - }, [setState_]) - - // Setup navigation listeners - React.useEffect(() => { - return listenNavigation() + router.setReactHandlers(setState, startTransition) + return router.listen() }, []) return ( <> - + {state.push && } ) } + /** + * Updates history via useInsertionEffect + */ + function HistoryUpdater({ url }: { url: string }) { + React.useInsertionEffect(() => { + router.commitHistoryPush(url) + }, [url]) + return null + } + /** * Visual indicator for pending transitions - * Only shows when actually fetching (cache miss) */ function TransitionStatus(props: { isPending: boolean }) { React.useEffect(() => { @@ -114,7 +97,6 @@ async function main() { /** * Renders the current navigation state - * Uses React.use() to unwrap the payload promise */ function RenderState({ state }: { state: NavigationState }) { const payload = React.use(state.payloadPromise) @@ -135,29 +117,24 @@ async function main() { }), { temporaryReferences }, ) - const payloadPromise = Promise.resolve(payload) - // Update cache for current history entry - bfCache.set(payloadPromise) - dispatch({ url: url.href, payload }) + router.handleServerAction(payload) return payload.returnValue }) // Hydrate root - const browserRoot = ( + hydrateRoot( + document, - + , + { formState: initialPayload.formState }, ) - hydrateRoot(document, browserRoot, { - formState: initialPayload.formState, - }) // HMR support if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { - // Invalidate cache for current entry on HMR - bfCache.set(undefined) - dispatch({ url: window.location.href }) + router.invalidateCache() + router.navigate(window.location.href) }) } } @@ -171,15 +148,6 @@ type NavigationState = { payloadPromise: Promise } -/** - * Navigation action shape - */ -type NavigationAction = { - url: string - push?: boolean - payload?: RscPayload -} - /** * History state with unique key per entry */ @@ -187,146 +155,202 @@ type HistoryState = null | { key?: string } -// Save reference to original history methods -const oldPushState = window.history.pushState -const oldReplaceState = window.history.replaceState - /** - * Back/Forward cache keyed by history state - * - * Each history entry gets a unique random key stored in history.state. - * Cache maps key → value, enabling instant back/forward navigation. + * Consolidated navigation router + * Encapsulates all navigation logic: history interception, caching, transitions */ -class BackForwardCache { - private cache: Record = {} +class Router { + private state: NavigationState + private cache = new BackForwardCache>() + private setState?: (state: NavigationState) => void + private startTransition?: (fn: () => void) => void + private oldPushState = window.history.pushState + private oldReplaceState = window.history.replaceState + + constructor(initialPayload: RscPayload) { + this.state = { + url: window.location.href, + push: false, + payloadPromise: Promise.resolve(initialPayload), + } + this.initializeHistoryState() + } /** - * Get cached value or run function to create it - * If current history state has a key and it's cached, return cached value. - * Otherwise run function, cache result, and return it. + * Get current state */ - run(fn: () => T): T { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - return (this.cache[key] ??= fn()) + getState(): NavigationState { + return this.state + } + + /** + * Connect router to React state handlers + */ + setReactHandlers( + setState: (state: NavigationState) => void, + startTransition: (fn: () => void) => void, + ) { + this.setState = setState + this.startTransition = startTransition + } + + /** + * Navigate to URL + */ + navigate(url: string, push = false) { + if (!this.setState || !this.startTransition) { + throw new Error('Router not connected to React') } - return fn() + + this.startTransition(() => { + this.state = { + url, + push, + payloadPromise: this.cache.run(() => + createFromFetch(fetch(url)), + ), + } + this.setState(this.state) + }) } /** - * Set value for current history entry - * Used to update cache after server actions or to invalidate (set undefined) + * Handle server action result */ - set(value: T | undefined) { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - if (value === undefined) { - delete this.cache[key] - } else { - this.cache[key] = value + handleServerAction(payload: RscPayload) { + const payloadPromise = Promise.resolve(payload) + this.cache.set(payloadPromise) + if (!this.setState || !this.startTransition) return + + this.startTransition(() => { + this.state = { + url: window.location.href, + push: false, + payloadPromise, } - } + this.setState(this.state) + }) } -} -/** - * Initialize history state with unique key if not present - */ -function initStateKey() { - if (!(window.history.state as HistoryState)?.key) { - oldReplaceState.call( - window.history, - addStateKey(window.history.state), - '', - window.location.href, - ) + /** + * Invalidate cache for current entry + */ + invalidateCache() { + this.cache.set(undefined) } -} -/** - * Add unique key to history state - */ -function addStateKey(state: any): HistoryState { - const key = Math.random().toString(36).slice(2) - return { ...state, key } -} + /** + * Commit history push (called from useInsertionEffect) + */ + commitHistoryPush(url: string) { + this.state.push = false + this.oldPushState.call(window.history, this.addStateKey({}), '', url) + } -/** - * Component that updates browser history via useInsertionEffect - * This ensures history updates happen AFTER the state update but BEFORE paint - */ -function HistoryUpdater({ state }: { state: NavigationState }) { - React.useInsertionEffect(() => { - if (state.push) { - state.push = false - oldPushState.call(window.history, addStateKey({}), '', state.url) + /** + * Setup history interception and listeners + */ + listen(): () => void { + // Intercept pushState + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldPushState.apply(window.history, args) + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href, false) // push flag handled by commitHistoryPush } - }, [state]) - return null -} + // Intercept replaceState + window.history.replaceState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldReplaceState.apply(window.history, args) + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href) + } -/** - * Setup navigation interception with history state keys - */ -function listenNavigation() { - // Initialize current history state with key - initStateKey() - - // Intercept pushState - window.history.pushState = function (...args) { - args[0] = addStateKey(args[0]) - const res = oldPushState.apply(this, args) - const url = new URL(args[2] || window.location.href, window.location.href) - dispatch({ url: url.href, push: false }) // push already happened above - return res + // Handle popstate (back/forward) + const onPopstate = () => { + this.navigate(window.location.href) + } + window.addEventListener('popstate', onPopstate) + + // Intercept link clicks + const onClick = (e: MouseEvent) => { + const 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() + window.history.pushState({}, '', link.href) + } + } + document.addEventListener('click', onClick) + + // Cleanup + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onPopstate) + window.history.pushState = this.oldPushState + window.history.replaceState = this.oldReplaceState + } } - // Intercept replaceState - window.history.replaceState = function (...args) { - args[0] = addStateKey(args[0]) - const res = oldReplaceState.apply(this, args) - const url = new URL(args[2] || window.location.href, window.location.href) - dispatch({ url: url.href }) - return res + /** + * Initialize history state with key if not present + */ + private initializeHistoryState() { + if (!(window.history.state as HistoryState)?.key) { + this.oldReplaceState.call( + window.history, + this.addStateKey(window.history.state), + '', + window.location.href, + ) + } } - // Handle back/forward navigation - function onPopstate() { - const href = window.location.href - dispatch({ url: href }) + /** + * Add unique key to history state + */ + private addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } } - window.addEventListener('popstate', onPopstate) - - // Intercept link clicks - function onClick(e: MouseEvent) { - const 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({}, '', link.href) +} + +/** + * Back/Forward cache keyed by history state + */ +class BackForwardCache { + private cache: Record = {} + + run(fn: () => T): T { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) } + return fn() } - document.addEventListener('click', onClick) - - // Cleanup - return () => { - document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onPopstate) - window.history.pushState = oldPushState - window.history.replaceState = oldReplaceState + + set(value: T | undefined) { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } } } From a07a29945204f3cb3fc0be66bc790bfe075e0b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:52:22 +0000 Subject: [PATCH 04/22] chore(plugin-rsc): cleanup navigation example config - Remove src/framework/react.d.ts (types now in tsconfig) - Replace tsconfig.json with starter example config - Uses @vitejs/plugin-rsc/types for type definitions Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../navigation/src/framework/react.d.ts | 1 - .../examples/navigation/tsconfig.json | 28 ++++++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) delete mode 100644 packages/plugin-rsc/examples/navigation/src/framework/react.d.ts diff --git a/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts b/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts deleted file mode 100644 index af5a1ad3e..000000000 --- a/packages/plugin-rsc/examples/navigation/src/framework/react.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/packages/plugin-rsc/examples/navigation/tsconfig.json b/packages/plugin-rsc/examples/navigation/tsconfig.json index 6d545f543..4c355ed3c 100644 --- a/packages/plugin-rsc/examples/navigation/tsconfig.json +++ b/packages/plugin-rsc/examples/navigation/tsconfig.json @@ -1,24 +1,18 @@ { "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", + "erasableSyntaxOnly": true, "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } } From 4424912b3ae9f8b91dc962fb789ed053d2d42b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:55:28 +0000 Subject: [PATCH 05/22] chore(plugin-rsc): remove vite-plugin-inspect from navigation example Remove vite-plugin-inspect dependency and usage to simplify the example. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/plugin-rsc/examples/navigation/package.json | 3 +-- packages/plugin-rsc/examples/navigation/vite.config.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/package.json b/packages/plugin-rsc/examples/navigation/package.json index 3c33e3d0c..1bcbe9858 100644 --- a/packages/plugin-rsc/examples/navigation/package.json +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -19,7 +19,6 @@ "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "latest", "rsc-html-stream": "^0.0.7", - "vite": "^7.0.5", - "vite-plugin-inspect": "^11.3.0" + "vite": "^7.0.5" } } diff --git a/packages/plugin-rsc/examples/navigation/vite.config.ts b/packages/plugin-rsc/examples/navigation/vite.config.ts index 9a0d19565..a8ab7440f 100644 --- a/packages/plugin-rsc/examples/navigation/vite.config.ts +++ b/packages/plugin-rsc/examples/navigation/vite.config.ts @@ -1,7 +1,6 @@ import rsc from '@vitejs/plugin-rsc' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' -import inspect from 'vite-plugin-inspect' export default defineConfig({ clearScreen: false, @@ -14,7 +13,6 @@ export default defineConfig({ rsc: './src/framework/entry.rsc.tsx', }, }), - !process.env.ECOSYSTEM_CI && inspect(), ], build: { minify: false, From 259c4813a891dc91a674841767a28ae1a03f0aa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:58:03 +0000 Subject: [PATCH 06/22] refactor(plugin-rsc): move Router to separate file Extract Router and BackForwardCache classes to src/framework/router.ts for better code organization and reusability. - Created router.ts with Router and BackForwardCache classes - Exported NavigationState type - Updated entry.browser.tsx to import from router module - entry.browser.tsx now focuses on React integration Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/framework/entry.browser.tsx | 216 +---------------- .../navigation/src/framework/router.ts | 217 ++++++++++++++++++ 2 files changed, 218 insertions(+), 215 deletions(-) create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/router.ts diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index ee3b01f44..09030824d 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -9,6 +9,7 @@ import React from 'react' import { hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' +import { Router, type NavigationState } from './router' /** * This example demonstrates coordinating history navigation with React transitions @@ -139,219 +140,4 @@ async function main() { } } -/** - * Navigation state shape - */ -type NavigationState = { - url: string - push?: boolean - payloadPromise: Promise -} - -/** - * History state with unique key per entry - */ -type HistoryState = null | { - key?: string -} - -/** - * Consolidated navigation router - * Encapsulates all navigation logic: history interception, caching, transitions - */ -class Router { - private state: NavigationState - private cache = new BackForwardCache>() - private setState?: (state: NavigationState) => void - private startTransition?: (fn: () => void) => void - private oldPushState = window.history.pushState - private oldReplaceState = window.history.replaceState - - constructor(initialPayload: RscPayload) { - this.state = { - url: window.location.href, - push: false, - payloadPromise: Promise.resolve(initialPayload), - } - this.initializeHistoryState() - } - - /** - * Get current state - */ - getState(): NavigationState { - return this.state - } - - /** - * Connect router to React state handlers - */ - setReactHandlers( - setState: (state: NavigationState) => void, - startTransition: (fn: () => void) => void, - ) { - this.setState = setState - this.startTransition = startTransition - } - - /** - * Navigate to URL - */ - navigate(url: string, push = false) { - if (!this.setState || !this.startTransition) { - throw new Error('Router not connected to React') - } - - this.startTransition(() => { - this.state = { - url, - push, - payloadPromise: this.cache.run(() => - createFromFetch(fetch(url)), - ), - } - this.setState(this.state) - }) - } - - /** - * Handle server action result - */ - handleServerAction(payload: RscPayload) { - const payloadPromise = Promise.resolve(payload) - this.cache.set(payloadPromise) - if (!this.setState || !this.startTransition) return - - this.startTransition(() => { - this.state = { - url: window.location.href, - push: false, - payloadPromise, - } - this.setState(this.state) - }) - } - - /** - * Invalidate cache for current entry - */ - invalidateCache() { - this.cache.set(undefined) - } - - /** - * Commit history push (called from useInsertionEffect) - */ - commitHistoryPush(url: string) { - this.state.push = false - this.oldPushState.call(window.history, this.addStateKey({}), '', url) - } - - /** - * Setup history interception and listeners - */ - listen(): () => void { - // Intercept pushState - window.history.pushState = (...args) => { - args[0] = this.addStateKey(args[0]) - this.oldPushState.apply(window.history, args) - const url = new URL(args[2] || window.location.href, window.location.href) - this.navigate(url.href, false) // push flag handled by commitHistoryPush - } - - // Intercept replaceState - window.history.replaceState = (...args) => { - args[0] = this.addStateKey(args[0]) - this.oldReplaceState.apply(window.history, args) - const url = new URL(args[2] || window.location.href, window.location.href) - this.navigate(url.href) - } - - // Handle popstate (back/forward) - const onPopstate = () => { - this.navigate(window.location.href) - } - window.addEventListener('popstate', onPopstate) - - // Intercept link clicks - const onClick = (e: MouseEvent) => { - const 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() - window.history.pushState({}, '', link.href) - } - } - document.addEventListener('click', onClick) - - // Cleanup - return () => { - document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onPopstate) - window.history.pushState = this.oldPushState - window.history.replaceState = this.oldReplaceState - } - } - - /** - * Initialize history state with key if not present - */ - private initializeHistoryState() { - if (!(window.history.state as HistoryState)?.key) { - this.oldReplaceState.call( - window.history, - this.addStateKey(window.history.state), - '', - window.location.href, - ) - } - } - - /** - * Add unique key to history state - */ - private addStateKey(state: any): HistoryState { - const key = Math.random().toString(36).slice(2) - return { ...state, key } - } -} - -/** - * Back/Forward cache keyed by history state - */ -class BackForwardCache { - private cache: Record = {} - - run(fn: () => T): T { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - return (this.cache[key] ??= fn()) - } - return fn() - } - - set(value: T | undefined) { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - if (value === undefined) { - delete this.cache[key] - } else { - this.cache[key] = value - } - } - } -} - main() diff --git a/packages/plugin-rsc/examples/navigation/src/framework/router.ts b/packages/plugin-rsc/examples/navigation/src/framework/router.ts new file mode 100644 index 000000000..db513f2d2 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/router.ts @@ -0,0 +1,217 @@ +import { createFromFetch } from '@vitejs/plugin-rsc/browser' +import type { RscPayload } from './entry.rsc' + +/** + * Navigation state shape + */ +export type NavigationState = { + url: string + push?: boolean + payloadPromise: Promise +} + +/** + * History state with unique key per entry + */ +type HistoryState = null | { + key?: string +} + +/** + * Consolidated navigation router + * Encapsulates all navigation logic: history interception, caching, transitions + */ +export class Router { + private state: NavigationState + private cache = new BackForwardCache>() + private setState?: (state: NavigationState) => void + private startTransition?: (fn: () => void) => void + private oldPushState = window.history.pushState + private oldReplaceState = window.history.replaceState + + constructor(initialPayload: RscPayload) { + this.state = { + url: window.location.href, + push: false, + payloadPromise: Promise.resolve(initialPayload), + } + this.initializeHistoryState() + } + + /** + * Get current state + */ + getState(): NavigationState { + return this.state + } + + /** + * Connect router to React state handlers + */ + setReactHandlers( + setState: (state: NavigationState) => void, + startTransition: (fn: () => void) => void, + ) { + this.setState = setState + this.startTransition = startTransition + } + + /** + * Navigate to URL + */ + navigate(url: string, push = false) { + if (!this.setState || !this.startTransition) { + throw new Error('Router not connected to React') + } + + this.startTransition(() => { + this.state = { + url, + push, + payloadPromise: this.cache.run(() => + createFromFetch(fetch(url)), + ), + } + this.setState(this.state) + }) + } + + /** + * Handle server action result + */ + handleServerAction(payload: RscPayload) { + const payloadPromise = Promise.resolve(payload) + this.cache.set(payloadPromise) + if (!this.setState || !this.startTransition) return + + this.startTransition(() => { + this.state = { + url: window.location.href, + push: false, + payloadPromise, + } + this.setState(this.state) + }) + } + + /** + * Invalidate cache for current entry + */ + invalidateCache() { + this.cache.set(undefined) + } + + /** + * Commit history push (called from useInsertionEffect) + */ + commitHistoryPush(url: string) { + this.state.push = false + this.oldPushState.call(window.history, this.addStateKey({}), '', url) + } + + /** + * Setup history interception and listeners + */ + listen(): () => void { + // Intercept pushState + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldPushState.apply(window.history, args) + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href, false) // push flag handled by commitHistoryPush + } + + // Intercept replaceState + window.history.replaceState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldReplaceState.apply(window.history, args) + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href) + } + + // Handle popstate (back/forward) + const onPopstate = () => { + this.navigate(window.location.href) + } + window.addEventListener('popstate', onPopstate) + + // Intercept link clicks + const onClick = (e: MouseEvent) => { + const 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() + window.history.pushState({}, '', link.href) + } + } + document.addEventListener('click', onClick) + + // Cleanup + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onPopstate) + window.history.pushState = this.oldPushState + window.history.replaceState = this.oldReplaceState + } + } + + /** + * Initialize history state with key if not present + */ + private initializeHistoryState() { + if (!(window.history.state as HistoryState)?.key) { + this.oldReplaceState.call( + window.history, + this.addStateKey(window.history.state), + '', + window.location.href, + ) + } + } + + /** + * Add unique key to history state + */ + private addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } + } +} + +/** + * Back/Forward cache keyed by history state + */ +class BackForwardCache { + private cache: Record = {} + + run(fn: () => T): T { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) + } + return fn() + } + + set(value: T | undefined) { + const key = (window.history.state as HistoryState)?.key + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } + } +} From f7a91c0660a379d6964df3c782f0aa33dc5a1b6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 12:28:09 +0000 Subject: [PATCH 07/22] refactor(plugin-rsc): rename Router to NavigationManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename for clarity: - router.ts → navigation.ts - Router class → NavigationManager class "NavigationManager" better describes the class's responsibility of managing all navigation concerns (history, cache, transitions). Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../examples/navigation/src/framework/entry.browser.tsx | 6 +++--- .../navigation/src/framework/{router.ts => navigation.ts} | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) rename packages/plugin-rsc/examples/navigation/src/framework/{router.ts => navigation.ts} (98%) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index 09030824d..fd9f41970 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -9,7 +9,7 @@ import React from 'react' import { hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' -import { Router, type NavigationState } from './router' +import { NavigationManager, type NavigationState } from './navigation' /** * This example demonstrates coordinating history navigation with React transitions @@ -29,8 +29,8 @@ async function main() { // Deserialize initial RSC stream from SSR const initialPayload = await createFromReadableStream(rscStream) - // Create router instance - const router = new Router(initialPayload) + // Create navigation manager instance + const router = new NavigationManager(initialPayload) // Browser root component function BrowserRoot() { diff --git a/packages/plugin-rsc/examples/navigation/src/framework/router.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts similarity index 98% rename from packages/plugin-rsc/examples/navigation/src/framework/router.ts rename to packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index db513f2d2..9850887fa 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/router.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -18,10 +18,10 @@ type HistoryState = null | { } /** - * Consolidated navigation router + * Navigation manager * Encapsulates all navigation logic: history interception, caching, transitions */ -export class Router { +export class NavigationManager { private state: NavigationState private cache = new BackForwardCache>() private setState?: (state: NavigationState) => void From 729017e576b0140117aa0a8ef567f660c6687f5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 13:11:03 +0000 Subject: [PATCH 08/22] feat(plugin-rsc): add Navigation API support to navigation example Add modern Navigation API support with automatic fallback to History API. Navigation API benefits: - Built-in unique keys per entry (navigation.currentEntry.key) - Single 'navigate' event replaces pushState/replaceState/popstate - e.canIntercept checks if navigation is interceptable - e.intercept() is cleaner than preventDefault + manual state - No useInsertionEffect coordination needed Implementation: - Feature detection: 'navigation' in window - NavigationManager.listenNavigationAPI() for modern browsers - NavigationManager.listenHistoryAPI() for fallback - BackForwardCache.getCurrentKey() uses appropriate source Browser support: - Navigation API: Chrome 102+, Edge 102+ - History API fallback: All browsers https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../plugin-rsc/examples/navigation/README.md | 22 +++-- .../navigation/src/framework/navigation.ts | 96 +++++++++++++++++-- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md index 334435660..9a6ed555b 100644 --- a/packages/plugin-rsc/examples/navigation/README.md +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -25,16 +25,25 @@ This example implements a caching pattern that addresses these issues: ### Key Concepts -1. **Back/Forward Cache by History Entry**: Each history entry gets a unique key, cache maps `key → Promise` -2. **Instant Navigation**: Cache hits render synchronously (no loading state), cache misses show transitions -3. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions -4. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` -5. **useInsertionEffect**: History updates happen via `useInsertionEffect` to ensure they occur after state updates but before paint +1. **Modern Navigation API**: Uses [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) when available, falls back to History API +2. **Back/Forward Cache by Entry**: Each navigation entry gets a unique key, cache maps `key → Promise` +3. **Instant Navigation**: Cache hits render synchronously (no loading state), cache misses show transitions +4. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions +5. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` 6. **Cache Invalidation**: Server actions update cache for current entry +### Browser Compatibility + +The implementation automatically detects and uses: + +- **Navigation API** (Chrome 102+, Edge 102+): Modern, cleaner API with built-in entry keys +- **History API** (all browsers): Fallback for older browsers, requires manual key management + +No configuration needed - feature detection happens automatically! + ### Implementation -The core implementation is in `src/framework/entry.browser.tsx`: +The core implementation is in `src/framework/navigation.ts`: ```typescript // Back/Forward cache keyed by history state @@ -121,6 +130,7 @@ Then navigate to http://localhost:5173 This pattern is inspired by: +- [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) - Modern navigation standard - [hi-ogawa/vite-environment-examples](https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server/src/features/router/browser.ts) - Back/forward cache implementation - [TanStack Router](https://github.com/TanStack/router/blob/main/packages/history/src/index.ts) - History state key pattern - [React useTransition](https://react.dev/reference/react/useTransition) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index 9850887fa..c8ce4c700 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -11,21 +11,30 @@ export type NavigationState = { } /** - * History state with unique key per entry + * History state with unique key per entry (History API fallback) */ type HistoryState = null | { key?: string } +/** + * Feature detection for Navigation API + */ +const supportsNavigationAPI = 'navigation' in window + /** * Navigation manager * Encapsulates all navigation logic: history interception, caching, transitions + * + * Uses modern Navigation API when available, falls back to History API + * https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API */ export class NavigationManager { private state: NavigationState private cache = new BackForwardCache>() private setState?: (state: NavigationState) => void private startTransition?: (fn: () => void) => void + // History API fallback private oldPushState = window.history.pushState private oldReplaceState = window.history.replaceState @@ -35,7 +44,9 @@ export class NavigationManager { push: false, payloadPromise: Promise.resolve(initialPayload), } - this.initializeHistoryState() + if (!supportsNavigationAPI) { + this.initializeHistoryState() + } } /** @@ -61,7 +72,7 @@ export class NavigationManager { */ navigate(url: string, push = false) { if (!this.setState || !this.startTransition) { - throw new Error('Router not connected to React') + throw new Error('NavigationManager not connected to React') } this.startTransition(() => { @@ -103,16 +114,69 @@ export class NavigationManager { /** * Commit history push (called from useInsertionEffect) + * Only needed for History API fallback */ commitHistoryPush(url: string) { + if (supportsNavigationAPI) return + this.state.push = false this.oldPushState.call(window.history, this.addStateKey({}), '', url) } /** - * Setup history interception and listeners + * Setup navigation interception and listeners */ listen(): () => void { + // Use modern Navigation API if available + if (supportsNavigationAPI) { + return this.listenNavigationAPI() + } + // Fallback to History API + return this.listenHistoryAPI() + } + + /** + * Setup listeners using modern Navigation API + * https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API + */ + private listenNavigationAPI(): () => void { + const onNavigate = (e: NavigateEvent) => { + // Skip non-interceptable navigations (e.g., cross-origin) + if (!e.canIntercept) { + return + } + + // Skip if navigation is to same URL + if (e.destination.url === window.location.href) { + return + } + + // Skip external links + const url = new URL(e.destination.url) + if (url.origin !== location.origin) { + return + } + + // Intercept navigation + e.intercept({ + handler: async () => { + // Navigation API automatically updates URL, no need for push flag + this.navigate(url.href, false) + }, + }) + } + + window.navigation.addEventListener('navigate', onNavigate as any) + + return () => { + window.navigation.removeEventListener('navigate', onNavigate as any) + } + } + + /** + * Setup listeners using History API (fallback for older browsers) + */ + private listenHistoryAPI(): () => void { // Intercept pushState window.history.pushState = (...args) => { args[0] = this.addStateKey(args[0]) @@ -168,7 +232,7 @@ export class NavigationManager { } /** - * Initialize history state with key if not present + * Initialize history state with key if not present (History API only) */ private initializeHistoryState() { if (!(window.history.state as HistoryState)?.key) { @@ -182,7 +246,7 @@ export class NavigationManager { } /** - * Add unique key to history state + * Add unique key to history state (History API only) */ private addStateKey(state: any): HistoryState { const key = Math.random().toString(36).slice(2) @@ -191,13 +255,16 @@ export class NavigationManager { } /** - * Back/Forward cache keyed by history state + * Back/Forward cache keyed by navigation entry + * + * Uses Navigation API's built-in keys when available, + * falls back to History API state keys */ class BackForwardCache { private cache: Record = {} run(fn: () => T): T { - const key = (window.history.state as HistoryState)?.key + const key = this.getCurrentKey() if (typeof key === 'string') { return (this.cache[key] ??= fn()) } @@ -205,7 +272,7 @@ class BackForwardCache { } set(value: T | undefined) { - const key = (window.history.state as HistoryState)?.key + const key = this.getCurrentKey() if (typeof key === 'string') { if (value === undefined) { delete this.cache[key] @@ -214,4 +281,15 @@ class BackForwardCache { } } } + + /** + * Get current entry key + * Uses Navigation API when available, falls back to History API + */ + private getCurrentKey(): string | undefined { + if (supportsNavigationAPI && window.navigation.currentEntry) { + return window.navigation.currentEntry.key + } + return (window.history.state as HistoryState)?.key + } } From 54a38f58c761bd588bf3952583cd514e22c63d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 13:11:29 +0000 Subject: [PATCH 09/22] docs(plugin-rsc): update README with Navigation API examples --- .../plugin-rsc/examples/navigation/README.md | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md index 9a6ed555b..84653d397 100644 --- a/packages/plugin-rsc/examples/navigation/README.md +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -46,28 +46,32 @@ No configuration needed - feature detection happens automatically! The core implementation is in `src/framework/navigation.ts`: ```typescript -// Back/Forward cache keyed by history state -class BackForwardCache { - private cache: Record = {} - - run(fn: () => T): T { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - return (this.cache[key] ??= fn()) // Cache hit returns immediately! - } - return fn() +// Feature detection +const supportsNavigationAPI = 'navigation' in window + +// Navigation API: Clean, modern +private listenNavigationAPI(): () => void { + const onNavigate = (e: NavigateEvent) => { + if (!e.canIntercept) return + + e.intercept({ + handler: async () => { + this.navigate(url.href) + }, + }) } + window.navigation.addEventListener('navigate', onNavigate) + return () => window.navigation.removeEventListener('navigate', onNavigate) +} - set(value: T | undefined) { - const key = (window.history.state as HistoryState)?.key - if (typeof key === 'string') { - if (value === undefined) { - delete this.cache[key] - } else { - this.cache[key] = value - } - } +// History API fallback: Works everywhere +private listenHistoryAPI(): () => void { + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldPushState.apply(window.history, args) + this.navigate(url.href) } + // ... popstate, replaceState, link clicks } // Dispatch coordinates navigation with transitions and cache From 3b1b582bec7643dcbac143dbf4fe214bfe547d48 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 23 Oct 2025 17:16:07 +0900 Subject: [PATCH 10/22] chore: deps --- .../examples/navigation/package.json | 12 ++++++------ pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/package.json b/packages/plugin-rsc/examples/navigation/package.json index 1bcbe9858..7fca5b163 100644 --- a/packages/plugin-rsc/examples/navigation/package.json +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -10,15 +10,15 @@ "preview": "vite preview" }, "dependencies": { - "@vitejs/plugin-rsc": "latest", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", "rsc-html-stream": "^0.0.7", - "vite": "^7.0.5" + "vite": "^7.1.10" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2f4a11c5..a68a2cf31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,30 +644,30 @@ importers: packages/plugin-rsc/examples/navigation: dependencies: - '@vitejs/plugin-rsc': - specifier: latest - version: link:../.. react: - specifier: ^19.1.0 + specifier: ^19.2.0 version: 19.2.0 react-dom: - specifier: ^19.1.0 + specifier: ^19.2.0 version: 19.2.0(react@19.2.0) devDependencies: '@types/react': - specifier: ^19.1.8 + specifier: ^19.2.2 version: 19.2.2 '@types/react-dom': - specifier: ^19.1.6 + specifier: ^19.2.2 version: 19.2.2(@types/react@19.2.2) '@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: ^7.0.5 + specifier: ^7.1.10 version: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) packages/plugin-rsc/examples/no-ssr: @@ -5882,7 +5882,7 @@ snapshots: '@types/hoist-non-react-statics@3.3.6': dependencies: - '@types/react': 19.2.2 + '@types/react': 18.3.20 hoist-non-react-statics: 3.3.2 '@types/json-schema@7.0.15': {} From bdb060b726d249d13de09d5062f217dbb74f1972 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 23 Oct 2025 17:31:42 +0900 Subject: [PATCH 11/22] chore: update example --- .../navigation/src/framework/entry.rsc.tsx | 80 ++++++++++++------- .../navigation/src/framework/entry.ssr.tsx | 62 +++++++++----- .../navigation/src/routes/counter-actions.tsx | 2 +- 3 files changed, 94 insertions(+), 50 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx index b1d5e2658..b98c4433f 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -1,55 +1,75 @@ -import * as ReactServer from '@vitejs/plugin-rsc/rsc' +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' -import { Root } from '../root.tsx' +import type React from 'react' +// 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?: unknown + // server action form state (e.g. useActionState) of progressive enhancement case formState?: ReactFormState } -export default async function handler(request: Request): Promise { - // Handle server action requests +// 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 e.g. `@cloudflare/vite-plugin`. +export async function handleRequest({ + request, + getRoot, + nonce, +}: { + request: Request + getRoot: () => React.ReactNode + nonce?: string +}): Promise { + // handle server function request const isAction = request.method === 'POST' let returnValue: unknown | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined - if (isAction) { + // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. const actionId = request.headers.get('x-rsc-action') if (actionId) { const contentType = request.headers.get('content-type') const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text() - temporaryReferences = ReactServer.createTemporaryReferenceSet() - const args = await ReactServer.decodeReply(body, { temporaryReferences }) - const action = await ReactServer.loadServerAction(actionId) + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) returnValue = await action.apply(null, args) } 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 ReactServer.decodeAction(formData) + const decodedAction = await decodeAction(formData) const result = await decodedAction() - formState = await ReactServer.decodeFormState(result, formData) + formState = await decodeFormState(result, formData) } } - // Parse URL to pass to Root component const url = new URL(request.url) - - // Render RSC payload - const rscPayload: RscPayload = { - root: , - formState, - returnValue, - } + const rscPayload: RscPayload = { root: getRoot(), formState, returnValue } const rscOptions = { temporaryReferences } - const rscStream = ReactServer.renderToReadableStream( - rscPayload, - rscOptions, - ) + const rscStream = renderToReadableStream(rscPayload, rscOptions) - // Determine if this is an RSC request or HTML request + // respond RSC stream without HTML rendering based on framework's convention. + // here we use request header `content-type`. + // additionally we allow `?__rsc` and `?__html` to easily view payload directly. const isRscRequest = (!request.headers.get('accept')?.includes('text/html') && !url.searchParams.has('__html')) || @@ -64,23 +84,25 @@ export default async function handler(request: Request): Promise { }) } - // Delegate to SSR for HTML rendering + // 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') const htmlStream = await ssrEntryModule.renderHTML(rscStream, { formState, + nonce, + // allow quick simulation of javscript disabled browser debugNojs: url.searchParams.has('__nojs'), }) + // respond html return new Response(htmlStream, { headers: { - 'Content-type': 'text/html', + 'content-type': 'text/html;charset=utf-8', vary: 'accept', }, }) } - -if (import.meta.hot) { - import.meta.hot.accept() -} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx index 20b1ecf41..e5c539923 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -1,34 +1,56 @@ -import * as ReactServer from '@vitejs/plugin-rsc/rsc' -import * as ReactDOMServer from 'react-dom/server' +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' import type { ReactFormState } from 'react-dom/client' -import { injectRscStreamToHtml } from 'rsc-html-stream/server' +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, + rscStream: ReadableStream, options: { formState?: ReactFormState + nonce?: string debugNojs?: boolean }, -): Promise { +) { + // 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 to React elements for SSR - const root = await ReactServer.createFromNodeStream( - rscStream1, - {}, - { clientManifest: import.meta.viteRsc.clientManifest }, - ) + // deserialize RSC stream back to React VDOM + let payload: Promise + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return {React.use(payload).root} + } - // Render to HTML stream - const htmlStream = await ReactDOMServer.renderToReadableStream(root, { - formState: options.formState, - bootstrapModules: options.debugNojs - ? [] - : [import.meta.viteRsc.clientManifest.entryModule], + function FixSsrThenable(props: React.PropsWithChildren) { + return props.children + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, }) - // Inject RSC stream into HTML for client hydration - const mergedStream = injectRscStreamToHtml(htmlStream, rscStream2) + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } - return mergedStream + return responseStream } diff --git a/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx b/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx index d93cb03a0..3db197cae 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx @@ -6,6 +6,6 @@ export async function incrementServerCounter() { serverCounter++ } -export function getServerCounter() { +export async function getServerCounter() { return serverCounter } From 74df4cbdcd3c5d34d8df91fe2bad1d707268c908 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 23 Oct 2025 17:36:55 +0900 Subject: [PATCH 12/22] cleanup --- .../navigation/src/framework/entry.rsc.tsx | 29 ++++++++++--------- .../navigation/src/framework/navigation.ts | 8 ++--- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx index b98c4433f..ab5a55a24 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -7,7 +7,7 @@ import { decodeFormState, } from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' -import type React from 'react' +import { Root } from '../root.tsx' // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -25,15 +25,7 @@ export type RscPayload = { // 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 e.g. `@cloudflare/vite-plugin`. -export async function handleRequest({ - request, - getRoot, - nonce, -}: { - request: Request - getRoot: () => React.ReactNode - nonce?: string -}): Promise { +export default async function handler(request: Request): Promise { // handle server function request const isAction = request.method === 'POST' let returnValue: unknown | undefined @@ -62,8 +54,16 @@ export async function handleRequest({ } } + // 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 url = new URL(request.url) - const rscPayload: RscPayload = { root: getRoot(), formState, returnValue } + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } const rscOptions = { temporaryReferences } const rscStream = renderToReadableStream(rscPayload, rscOptions) @@ -93,7 +93,6 @@ export async function handleRequest({ >('ssr', 'index') const htmlStream = await ssrEntryModule.renderHTML(rscStream, { formState, - nonce, // allow quick simulation of javscript disabled browser debugNojs: url.searchParams.has('__nojs'), }) @@ -101,8 +100,12 @@ export async function handleRequest({ // respond html return new Response(htmlStream, { headers: { - 'content-type': 'text/html;charset=utf-8', + 'Content-type': 'text/html', vary: 'accept', }, }) } + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index c8ce4c700..2d7b63c78 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -32,7 +32,7 @@ const supportsNavigationAPI = 'navigation' in window export class NavigationManager { private state: NavigationState private cache = new BackForwardCache>() - private setState?: (state: NavigationState) => void + private setState!: (state: NavigationState) => void private startTransition?: (fn: () => void) => void // History API fallback private oldPushState = window.history.pushState @@ -128,9 +128,9 @@ export class NavigationManager { */ listen(): () => void { // Use modern Navigation API if available - if (supportsNavigationAPI) { - return this.listenNavigationAPI() - } + // if (supportsNavigationAPI) { + // return this.listenNavigationAPI() + // } // Fallback to History API return this.listenHistoryAPI() } From 2066f187b2a2b7605cf0e64c734100f842ad760c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 23 Oct 2025 18:07:14 +0900 Subject: [PATCH 13/22] chore: remove navigation api --- .../navigation/src/framework/navigation.ts | 67 +------------------ 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index 2d7b63c78..8375647eb 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -17,17 +17,9 @@ type HistoryState = null | { key?: string } -/** - * Feature detection for Navigation API - */ -const supportsNavigationAPI = 'navigation' in window - /** * Navigation manager * Encapsulates all navigation logic: history interception, caching, transitions - * - * Uses modern Navigation API when available, falls back to History API - * https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API */ export class NavigationManager { private state: NavigationState @@ -44,9 +36,7 @@ export class NavigationManager { push: false, payloadPromise: Promise.resolve(initialPayload), } - if (!supportsNavigationAPI) { - this.initializeHistoryState() - } + this.initializeHistoryState() } /** @@ -117,8 +107,6 @@ export class NavigationManager { * Only needed for History API fallback */ commitHistoryPush(url: string) { - if (supportsNavigationAPI) return - this.state.push = false this.oldPushState.call(window.history, this.addStateKey({}), '', url) } @@ -127,56 +115,6 @@ export class NavigationManager { * Setup navigation interception and listeners */ listen(): () => void { - // Use modern Navigation API if available - // if (supportsNavigationAPI) { - // return this.listenNavigationAPI() - // } - // Fallback to History API - return this.listenHistoryAPI() - } - - /** - * Setup listeners using modern Navigation API - * https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API - */ - private listenNavigationAPI(): () => void { - const onNavigate = (e: NavigateEvent) => { - // Skip non-interceptable navigations (e.g., cross-origin) - if (!e.canIntercept) { - return - } - - // Skip if navigation is to same URL - if (e.destination.url === window.location.href) { - return - } - - // Skip external links - const url = new URL(e.destination.url) - if (url.origin !== location.origin) { - return - } - - // Intercept navigation - e.intercept({ - handler: async () => { - // Navigation API automatically updates URL, no need for push flag - this.navigate(url.href, false) - }, - }) - } - - window.navigation.addEventListener('navigate', onNavigate as any) - - return () => { - window.navigation.removeEventListener('navigate', onNavigate as any) - } - } - - /** - * Setup listeners using History API (fallback for older browsers) - */ - private listenHistoryAPI(): () => void { // Intercept pushState window.history.pushState = (...args) => { args[0] = this.addStateKey(args[0]) @@ -287,9 +225,6 @@ class BackForwardCache { * Uses Navigation API when available, falls back to History API */ private getCurrentKey(): string | undefined { - if (supportsNavigationAPI && window.navigation.currentEntry) { - return window.navigation.currentEntry.key - } return (window.history.state as HistoryState)?.key } } From b6efc4616254d8bd30a40ada1645858562f929c3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 28 Oct 2025 17:02:30 +0900 Subject: [PATCH 14/22] cleanup --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56e1a9f6f..dc1daf6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,7 +668,7 @@ importers: version: 0.0.7 vite: specifier: ^7.1.10 - version: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + version: 7.1.10(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) packages/plugin-rsc/examples/no-ssr: dependencies: From 1c2f345a3aafe851b406b45271c91633a5961148 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 28 Oct 2025 17:18:16 +0900 Subject: [PATCH 15/22] refactor(plugin-rsc): remove comments from navigation example entry files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/framework/entry.browser.tsx | 30 ------------------- .../navigation/src/framework/entry.rsc.tsx | 28 ----------------- .../navigation/src/framework/entry.ssr.tsx | 8 ----- 3 files changed, 66 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index fd9f41970..00ecf598a 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -11,33 +11,15 @@ import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' import { NavigationManager, type NavigationState } from './navigation' -/** - * This example demonstrates coordinating history navigation with React transitions - * and caching RSC payloads by history entry. - * - * Key features: - * 1. Back/forward navigation is instant via cache (no loading state) - * 2. Cache is keyed by history state, not URL - * 3. Server actions invalidate cache for current entry - * 4. All navigation logic consolidated in Router class - * - * Pattern inspired by: - * https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server - */ - async function main() { - // Deserialize initial RSC stream from SSR const initialPayload = await createFromReadableStream(rscStream) - // Create navigation manager instance const router = new NavigationManager(initialPayload) - // Browser root component function BrowserRoot() { const [state, setState] = React.useState(router.getState()) const [isPending, startTransition] = React.useTransition() - // Connect router to React state React.useEffect(() => { router.setReactHandlers(setState, startTransition) return router.listen() @@ -52,9 +34,6 @@ async function main() { ) } - /** - * Updates history via useInsertionEffect - */ function HistoryUpdater({ url }: { url: string }) { React.useInsertionEffect(() => { router.commitHistoryPush(url) @@ -62,9 +41,6 @@ async function main() { return null } - /** - * Visual indicator for pending transitions - */ function TransitionStatus(props: { isPending: boolean }) { React.useEffect(() => { let el = document.querySelector('#pending') as HTMLDivElement @@ -96,15 +72,11 @@ async function main() { return null } - /** - * Renders the current navigation state - */ function RenderState({ state }: { state: NavigationState }) { const payload = React.use(state.payloadPromise) return payload.root } - // Register server callback for server actions setServerCallback(async (id, args) => { const url = new URL(window.location.href) const temporaryReferences = createTemporaryReferenceSet() @@ -122,7 +94,6 @@ async function main() { return payload.returnValue }) - // Hydrate root hydrateRoot( document, @@ -131,7 +102,6 @@ async function main() { { formState: initialPayload.formState }, ) - // HMR support if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { router.invalidateCache() diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx index ab5a55a24..9baec56fe 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -9,30 +9,18 @@ import { import type { ReactFormState } from 'react-dom/client' import { Root } from '../root.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?: 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 e.g. `@cloudflare/vite-plugin`. export default async function handler(request: Request): Promise { - // handle server function request const isAction = request.method === 'POST' let returnValue: unknown | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined if (isAction) { - // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. const actionId = request.headers.get('x-rsc-action') if (actionId) { const contentType = request.headers.get('content-type') @@ -44,9 +32,6 @@ export default async function handler(request: Request): Promise { const action = await loadServerAction(actionId) returnValue = await action.apply(null, args) } 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) const result = await decodedAction() @@ -54,10 +39,6 @@ export default async function handler(request: Request): Promise { } } - // 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 url = new URL(request.url) const rscPayload: RscPayload = { root: , @@ -67,9 +48,6 @@ export default async function handler(request: Request): Promise { const rscOptions = { temporaryReferences } const rscStream = renderToReadableStream(rscPayload, rscOptions) - // respond RSC stream without HTML rendering based on framework's convention. - // here we use request header `content-type`. - // additionally we allow `?__rsc` and `?__html` to easily view payload directly. const isRscRequest = (!request.headers.get('accept')?.includes('text/html') && !url.searchParams.has('__html')) || @@ -84,20 +62,14 @@ export default async function handler(request: Request): Promise { }) } - // 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') const htmlStream = await ssrEntryModule.renderHTML(rscStream, { formState, - // allow quick simulation of javscript disabled browser debugNojs: url.searchParams.has('__nojs'), }) - // respond html return new Response(htmlStream, { headers: { 'Content-type': 'text/html', diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx index e5c539923..8c2c4d531 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -13,16 +13,10 @@ export async function renderHTML( debugNojs?: boolean }, ) { - // 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 function SsrRoot() { - // deserialization needs to be kicked off inside ReactDOMServer context - // for ReactDomServer preinit/preloading to work payload ??= createFromReadableStream(rscStream1) return {React.use(payload).root} } @@ -31,7 +25,6 @@ export async function renderHTML( return props.children } - // render html (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index') const htmlStream = await renderToReadableStream(, { @@ -44,7 +37,6 @@ export async function renderHTML( let responseStream: ReadableStream = htmlStream if (!options?.debugNojs) { - // initial RSC stream is injected in HTML stream as responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, From 54d84191e0e3dfd9743c62158ff069547aed9ec5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 29 Oct 2025 11:35:28 +0900 Subject: [PATCH 16/22] comment --- .../examples/navigation/src/framework/entry.browser.tsx | 2 ++ .../examples/navigation/src/framework/navigation.ts | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index 00ecf598a..6f60fed96 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -20,6 +20,7 @@ async function main() { const [state, setState] = React.useState(router.getState()) const [isPending, startTransition] = React.useTransition() + // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/use-action-queue.ts#L49 React.useEffect(() => { router.setReactHandlers(setState, startTransition) return router.listen() @@ -34,6 +35,7 @@ async function main() { ) } + // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 function HistoryUpdater({ url }: { url: string }) { React.useInsertionEffect(() => { router.commitHistoryPush(url) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index 8375647eb..ad06d81ba 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -1,9 +1,8 @@ import { createFromFetch } from '@vitejs/plugin-rsc/browser' import type { RscPayload } from './entry.rsc' -/** - * Navigation state shape - */ +// https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/client/components/app-router-instance.ts +// https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/client/components/app-router.tsx export type NavigationState = { url: string push?: boolean From 1dfb033ca3bb6c5a55a91a8e2b35b049e8bd9353 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 29 Oct 2025 11:44:26 +0900 Subject: [PATCH 17/22] comment --- .../examples/navigation/src/framework/navigation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index ad06d81ba..5fa5c2c83 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -131,7 +131,9 @@ export class NavigationManager { } // Handle popstate (back/forward) - const onPopstate = () => { + const onPopstate = (e: PopStateEvent) => { + // TODO: use state key from event to look up cache + e.state.key this.navigate(window.location.href) } window.addEventListener('popstate', onPopstate) @@ -182,9 +184,7 @@ export class NavigationManager { } } - /** - * Add unique key to history state (History API only) - */ + // https://github.com/TanStack/router/blob/05941e5ef2b7d2776e885cf473fdcc3970548b22/packages/history/src/index.ts private addStateKey(state: any): HistoryState { const key = Math.random().toString(36).slice(2) return { ...state, key } From 79c71db6b8ddc176c6079d7f20b41ad20145897e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 29 Oct 2025 11:48:59 +0900 Subject: [PATCH 18/22] cleanup --- .../navigation/src/framework/entry.browser.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index 6f60fed96..943bd79d3 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -14,16 +14,16 @@ import { NavigationManager, type NavigationState } from './navigation' async function main() { const initialPayload = await createFromReadableStream(rscStream) - const router = new NavigationManager(initialPayload) + const manager = new NavigationManager(initialPayload) function BrowserRoot() { - const [state, setState] = React.useState(router.getState()) + const [state, setState] = React.useState(manager.getState()) const [isPending, startTransition] = React.useTransition() // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/use-action-queue.ts#L49 React.useEffect(() => { - router.setReactHandlers(setState, startTransition) - return router.listen() + manager.setReactHandlers(setState, startTransition) + return manager.listen() }, []) return ( @@ -38,7 +38,7 @@ async function main() { // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 function HistoryUpdater({ url }: { url: string }) { React.useInsertionEffect(() => { - router.commitHistoryPush(url) + manager.commitHistoryPush(url) }, [url]) return null } @@ -92,7 +92,7 @@ async function main() { }), { temporaryReferences }, ) - router.handleServerAction(payload) + manager.handleServerAction(payload) return payload.returnValue }) @@ -106,8 +106,8 @@ async function main() { if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { - router.invalidateCache() - router.navigate(window.location.href) + manager.invalidateCache() + manager.navigate(window.location.href) }) } } From 58c52015810e9c213af54b44ce53a995b9fecf48 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 29 Oct 2025 11:49:54 +0900 Subject: [PATCH 19/22] cleanup --- packages/plugin-rsc/examples/navigation/README.md | 4 +++- .../examples/navigation/src/framework/navigation.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md index 84653d397..63505f803 100644 --- a/packages/plugin-rsc/examples/navigation/README.md +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -1,6 +1,8 @@ # Navigation Example - Coordinating History, Transitions, and Caching -This example demonstrates how to properly coordinate browser history navigation with React transitions and implement instant back/forward navigation via caching in a React Server Components application. +TODO: review + +This example demonstrates how to properly coordinate Browser URL update with React transitions and implement instant back/forward navigation via caching in a React Server Components application. ## Problem diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index 5fa5c2c83..8c87a9636 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -117,7 +117,7 @@ export class NavigationManager { // Intercept pushState window.history.pushState = (...args) => { args[0] = this.addStateKey(args[0]) - this.oldPushState.apply(window.history, args) + this.oldPushState.apply(window.history, args) // TODO: no. shouldn't commit url yet const url = new URL(args[2] || window.location.href, window.location.href) this.navigate(url.href, false) // push flag handled by commitHistoryPush } @@ -125,7 +125,7 @@ export class NavigationManager { // Intercept replaceState window.history.replaceState = (...args) => { args[0] = this.addStateKey(args[0]) - this.oldReplaceState.apply(window.history, args) + this.oldReplaceState.apply(window.history, args) // TODO: no. shouldn't commit url yet const url = new URL(args[2] || window.location.href, window.location.href) this.navigate(url.href) } From d66560e165b9aa77868cc1b47d0a35cb05103a80 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 29 Oct 2025 14:07:55 +0900 Subject: [PATCH 20/22] cleanup --- .../examples/navigation/src/index.css | 11 ++- .../examples/navigation/src/root.tsx | 10 --- .../navigation/src/routes/counter-actions.tsx | 11 --- .../navigation/src/routes/counter.tsx | 68 ------------------- .../examples/navigation/src/routes/slow.tsx | 2 +- 5 files changed, 6 insertions(+), 96 deletions(-) delete mode 100644 packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx delete mode 100644 packages/plugin-rsc/examples/navigation/src/routes/counter.tsx diff --git a/packages/plugin-rsc/examples/navigation/src/index.css b/packages/plugin-rsc/examples/navigation/src/index.css index de3530235..85ef7939d 100644 --- a/packages/plugin-rsc/examples/navigation/src/index.css +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -23,18 +23,13 @@ body { min-height: 100vh; } -.app { - max-width: 1200px; - margin: 0 auto; -} - .nav { background: rgba(255, 255, 255, 0.05); padding: 1rem 2rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; + justify-content: center; align-items: center; - justify-content: space-between; flex-wrap: wrap; gap: 1rem; } @@ -71,6 +66,10 @@ body { } .main { + display: flex; + justify-content: center; + max-width: 1200px; + margin: 0 auto; padding: 2rem; } diff --git a/packages/plugin-rsc/examples/navigation/src/root.tsx b/packages/plugin-rsc/examples/navigation/src/root.tsx index b74c1ac6e..ec76e1e07 100644 --- a/packages/plugin-rsc/examples/navigation/src/root.tsx +++ b/packages/plugin-rsc/examples/navigation/src/root.tsx @@ -2,7 +2,6 @@ import './index.css' import { HomePage } from './routes/home' import { AboutPage } from './routes/about' import { SlowPage } from './routes/slow' -import { CounterPage } from './routes/counter' export function Root(props: { url: URL }) { const pathname = props.url.pathname @@ -16,9 +15,6 @@ export function Root(props: { url: URL }) { } else if (pathname === '/slow') { page = title = 'Slow Page - Navigation Example' - } else if (pathname === '/counter') { - page = - title = 'Counter - Navigation Example' } else { page = title = 'Home - Navigation Example' @@ -49,12 +45,6 @@ export function Root(props: { url: URL }) { Slow Page - - Counter -
{page}
diff --git a/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx b/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx deleted file mode 100644 index 3db197cae..000000000 --- a/packages/plugin-rsc/examples/navigation/src/routes/counter-actions.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use server' - -let serverCounter = 0 - -export async function incrementServerCounter() { - serverCounter++ -} - -export async function getServerCounter() { - return serverCounter -} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx b/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx deleted file mode 100644 index e6c5d7e1d..000000000 --- a/packages/plugin-rsc/examples/navigation/src/routes/counter.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client' - -import { useState } from 'react' -import { incrementServerCounter, getServerCounter } from './counter-actions' - -/** - * This page demonstrates navigation with both client and server state. - */ -export function CounterPage() { - const [clientCount, setClientCount] = useState(0) - - return ( -
-

Counter Page

-

- This page demonstrates client and server state management with - coordinated navigation. -

-
-

Client Counter

-

Current count: {clientCount}

-
- - -
-

- This counter is managed on the client. Notice that it resets when you - navigate away and back. -

-
-
-

Server Counter

- -

- This counter is managed on the server. It persists across navigations - because it's part of the server state. -

-
-
-

Try this:

-
    -
  1. Increment both counters
  2. -
  3. Navigate to another page
  4. -
  5. Navigate back to this page
  6. -
  7. - Notice that the client counter resets but the server counter - persists -
  8. -
-
-
- ) -} - -function ServerCounter() { - const count = getServerCounter() - - return ( - <> -

Current count: {count}

- - - - - ) -} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx index ab47450d8..a41a5bec9 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx @@ -3,7 +3,7 @@ * the navigation transition coordination. */ export async function SlowPage(props: { url: URL }) { - const delay = Number(props.url.searchParams.get('delay')) || 2000 + const delay = Number(props.url.searchParams.get('delay')) || 500 // Simulate slow server response await new Promise((resolve) => setTimeout(resolve, delay)) From 99126ac4657cf6f620cd0fae3b9057108bb41c02 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 13 Nov 2025 19:03:07 +0900 Subject: [PATCH 21/22] lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96fdb5fb5..1efa600df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,7 +668,7 @@ importers: version: 0.0.7 vite: specifier: ^7.1.10 - version: 7.1.10(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) packages/plugin-rsc/examples/no-ssr: dependencies: From a57b4aec6483ad5fb075a63752ef010c49b08647 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 13 Nov 2025 19:06:40 +0900 Subject: [PATCH 22/22] add GlobalErrorBoundary --- .../src/framework/entry.browser.tsx | 5 +- .../src/framework/error-boundary.tsx | 81 +++++++++++++++++++ .../navigation/src/framework/request.tsx | 60 ++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/request.tsx diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx index 943bd79d3..6709eb9ad 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -10,6 +10,7 @@ import { hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' import { NavigationManager, type NavigationState } from './navigation' +import { GlobalErrorBoundary } from './error-boundary' async function main() { const initialPayload = await createFromReadableStream(rscStream) @@ -99,7 +100,9 @@ async function main() { hydrateRoot( document, - + + + , { formState: initialPayload.formState }, ) diff --git a/packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/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/navigation/src/framework/request.tsx b/packages/plugin-rsc/examples/navigation/src/framework/request.tsx new file mode 100644 index 000000000..5f61b8f1a --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/request.tsx @@ -0,0 +1,60 @@ +// TODO + +// 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, + } + } +}