From bde18b7d1a95bc3c3e3a443f392d161d0edbea0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Oct 2025 11:20:55 +0000 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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, + } + } +} From ccee46bddde77ccadb09ee28fbd360df3e0b9954 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 15:56:17 +0900 Subject: [PATCH 23/30] chore: lockfile --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2388b10f..36502701d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -653,10 +653,10 @@ importers: devDependencies: '@types/react': specifier: ^19.2.2 - version: 19.2.2 + version: 19.2.5 '@types/react-dom': specifier: ^19.2.2 - version: 19.2.2(@types/react@19.2.2) + version: 19.2.3(@types/react@19.2.5) '@vitejs/plugin-react': specifier: latest version: link:../../../plugin-react @@ -5675,7 +5675,7 @@ snapshots: '@types/hoist-non-react-statics@3.3.6': dependencies: - '@types/react': 18.3.20 + '@types/react': 19.2.5 hoist-non-react-statics: 3.3.2 '@types/json-schema@7.0.15': {} From 50e6a233cbb6923f8161021aa71a60f86aedca8d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 16:01:16 +0900 Subject: [PATCH 24/30] cleanup --- .../examples/navigation/src/routes/home.tsx | 81 ------------------- 1 file changed, 81 deletions(-) diff --git a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx index f95a4df98..e5ee169c9 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -6,36 +6,6 @@ export function HomePage() { This example demonstrates coordinating browser history navigation with 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 (only for cache - misses) -
  • -
  • - Promise-based State: Navigation state includes a{' '} - payloadPromise unwrapped with React.use() -
  • -
  • - Cache Invalidation: Server actions update cache for - current entry -
  • -
-

Try it out

@@ -49,9 +19,6 @@ export function HomePage() {

  • Slow Page - Simulates a slow server response
  • -
  • - Counter - A page with server and client state -
  • Notice the cache behavior: @@ -70,54 +37,6 @@ export function HomePage() {

    -
    -

    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 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 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 - implementation. -

    -
    ) } From 5d56c00352676ca897d63e8846ca261365836cd1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 16:05:33 +0900 Subject: [PATCH 25/30] chore: tweak style --- packages/plugin-rsc/examples/navigation/src/index.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/plugin-rsc/examples/navigation/src/index.css b/packages/plugin-rsc/examples/navigation/src/index.css index 85ef7939d..c5f225835 100644 --- a/packages/plugin-rsc/examples/navigation/src/index.css +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -65,6 +65,11 @@ body { color: white; } +.nav-links a.active:hover { + background: #535bf2; + border-color: transparent; +} + .main { display: flex; justify-content: center; From 0458f0ffa79bf8bb108bece03421aee38a679279 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 16:16:58 +0900 Subject: [PATCH 26/30] chore: "_.rsc" for rsc request --- .../src/framework/entry.browser.tsx | 39 +++++----- .../navigation/src/framework/entry.rsc.tsx | 72 +++++++++++++------ .../navigation/src/framework/entry.ssr.tsx | 56 +++++++++++---- .../navigation/src/framework/navigation.ts | 3 +- 4 files changed, 114 insertions(+), 56 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 6709eb9ad..676752875 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -6,11 +6,12 @@ import { encodeReply, } from '@vitejs/plugin-rsc/browser' import React from 'react' -import { hydrateRoot } from 'react-dom/client' +import { createRoot, hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' import { NavigationManager, type NavigationState } from './navigation' import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' async function main() { const initialPayload = await createFromReadableStream(rscStream) @@ -81,32 +82,36 @@ async function main() { } 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 }, - ) + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) manager.handleServerAction(payload) - return payload.returnValue + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data }) - hydrateRoot( - document, + const browserRoot = ( - , - { formState: initialPayload.formState }, + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { manager.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 9baec56fe..06512e198 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -8,56 +8,80 @@ import { } from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. root: React.ReactNode - returnValue?: unknown + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case formState?: ReactFormState } export default async function handler(request: Request): Promise { - const isAction = request.method === 'POST' - let returnValue: unknown | undefined + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined - if (isAction) { - const actionId = request.headers.get('x-rsc-action') - if (actionId) { + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. const contentType = request.headers.get('content-type') const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text() temporaryReferences = createTemporaryReferenceSet() const args = await decodeReply(body, { temporaryReferences }) - const action = await loadServerAction(actionId) - returnValue = await action.apply(null, args) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } } else { + // otherwise server function is called via `
    ` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. const formData = await request.formData() const decodedAction = await decodeAction(formData) - const result = await decodedAction() - formState = await decodeFormState(result, formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } } } - const url = new URL(request.url) const rscPayload: RscPayload = { - root: , + root: , formState, returnValue, } const rscOptions = { temporaryReferences } const rscStream = renderToReadableStream(rscPayload, rscOptions) - const isRscRequest = - (!request.headers.get('accept')?.includes('text/html') && - !url.searchParams.has('__html')) || - url.searchParams.has('__rsc') - - if (isRscRequest) { + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { return new Response(rscStream, { + status: actionStatus, headers: { 'content-type': 'text/x-component;charset=utf-8', - vary: 'accept', }, }) } @@ -65,15 +89,17 @@ export default async function handler(request: Request): Promise { const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import('./entry.ssr.tsx') >('ssr', 'index') - const htmlStream = await ssrEntryModule.renderHTML(rscStream, { + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, - debugNojs: url.searchParams.has('__nojs'), + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), }) - return new Response(htmlStream, { + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, headers: { 'Content-type': 'text/html', - vary: '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 8c2c4d531..7fc5a9564 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -12,31 +12,57 @@ export async function renderHTML( nonce?: string debugNojs?: boolean }, -) { +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . const [rscStream1, rscStream2] = rscStream.tee() - let payload: Promise + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined 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} - } - - function FixSsrThenable(props: React.PropsWithChildren) { - return props.children + return React.use(payload).root } + // 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, - }) + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } let responseStream: ReadableStream = htmlStream if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, @@ -44,5 +70,5 @@ export async function renderHTML( ) } - return responseStream + return { stream: responseStream, status } } diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts index 8c87a9636..c010147f5 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -1,5 +1,6 @@ import { createFromFetch } from '@vitejs/plugin-rsc/browser' import type { RscPayload } from './entry.rsc' +import { createRscRenderRequest } from './request' // 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 @@ -69,7 +70,7 @@ export class NavigationManager { url, push, payloadPromise: this.cache.run(() => - createFromFetch(fetch(url)), + createFromFetch(fetch(createRscRenderRequest(url))), ), } this.setState(this.state) From 56f4a24ffea054464ddfa1b87fa13681057c7190 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 18:29:20 +0900 Subject: [PATCH 27/30] tweak --- .../examples/navigation/src/framework/entry.browser.tsx | 4 ++++ packages/plugin-rsc/examples/navigation/src/index.css | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 676752875..c33ef61a3 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -18,6 +18,10 @@ async function main() { const manager = new NavigationManager(initialPayload) + function Router(props: React.PropsWithChildren<{}>) { + return props.children + } + function BrowserRoot() { const [state, setState] = React.useState(manager.getState()) const [isPending, startTransition] = React.useTransition() diff --git a/packages/plugin-rsc/examples/navigation/src/index.css b/packages/plugin-rsc/examples/navigation/src/index.css index c5f225835..2542dddb0 100644 --- a/packages/plugin-rsc/examples/navigation/src/index.css +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -24,7 +24,11 @@ body { } .nav { + position: sticky; + top: 0; + z-index: 100; background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); padding: 1rem 2rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; @@ -198,7 +202,8 @@ form { } .nav { - background: rgba(0, 0, 0, 0.03); + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); border-bottom-color: rgba(0, 0, 0, 0.1); } From 1119c415c4624d732df9dbbf7c15b2c398a30300 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 19 Nov 2025 18:33:51 +0900 Subject: [PATCH 28/30] more demo --- .../src/framework/entry.browser.tsx | 4 - .../examples/navigation/src/routes/about.tsx | 100 +++++++++++++++ .../examples/navigation/src/routes/home.tsx | 105 ++++++++++++++++ .../examples/navigation/src/routes/slow.tsx | 117 ++++++++++++++++++ 4 files changed, 322 insertions(+), 4 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 c33ef61a3..676752875 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -18,10 +18,6 @@ async function main() { const manager = new NavigationManager(initialPayload) - function Router(props: React.PropsWithChildren<{}>) { - return props.children - } - function BrowserRoot() { const [state, setState] = React.useState(manager.getState()) const [isPending, startTransition] = React.useTransition() diff --git a/packages/plugin-rsc/examples/navigation/src/routes/about.tsx b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx index baf6a196d..26dcb76dc 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/about.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx @@ -30,6 +30,106 @@ export function AboutPage() { page is re-rendered on the server each time.

    +
    +

    Section 1: What are RSCs?

    +

    + React Server Components are a new type of component that runs only on + the server. They can directly access server-side resources like + databases, file systems, or APIs without needing to create separate + API endpoints. +

    +

    + This architectural pattern reduces the amount of JavaScript shipped to + the client and enables better performance for data-heavy applications. +

    +
    +
    +

    Section 2: Key Differences

    +

    Unlike client components, server components:

    +
      +
    • Cannot use React hooks like useState or useEffect
    • +
    • Cannot handle browser events directly
    • +
    • Can be async functions that await data
    • +
    • Don't add to the client JavaScript bundle
    • +
    • Can import and use server-only packages safely
    • +
    +
    +
    +

    Section 3: Composition Patterns

    +

    + Server and client components can be composed together seamlessly. A + common pattern is to have server components fetch data and pass it as + props to client components that handle interactivity. +

    +

    + This separation of concerns creates a clean architecture where data + fetching and rendering logic stay on the server, while interactive + features run on the client. +

    +
    +
    +

    Section 4: Streaming Benefits

    +

    + RSCs support streaming, meaning the server can start sending UI to the + client before all data is ready. This creates a progressive loading + experience where users see content incrementally rather than waiting + for everything to load. +

    +

    + Suspense boundaries can be used to define loading states for different + parts of the page, enabling fine-grained control over the streaming + behavior. +

    +
    +
    +

    Section 5: Caching Strategies

    +

    + In this navigation example, we implement a simple but effective + caching strategy. Each time you visit a page, the RSC payload is + cached and associated with the browser history entry. +

    +

    + This means when you use back/forward navigation, the page loads + instantly from cache. The cache persists for the session, providing a + smooth browsing experience. +

    +
    +
    +

    Section 6: Performance Metrics

    +

    + By keeping heavy rendering logic on the server, RSCs can significantly + improve metrics like Time to Interactive (TTI) and First Input Delay + (FID). The reduced JavaScript bundle means faster parsing and + execution. +

    +

    + Additionally, server-side rendering enables better SEO and faster + First Contentful Paint (FCP) for initial page loads. +

    +
    +
    +

    Section 7: Scroll Testing Area

    +

    + Scroll to this section and remember its position. Then navigate to + another page and come back using the browser back button. +

    +

    + The browser will automatically restore your scroll position to this + exact location, demonstrating native scroll restoration working with + our coordinated navigation. +

    +
    +
    +

    Section 8: End of About Page

    +

    + This is the end of the About page. Notice the timestamp at the top - + it updates each time you navigate to this page (not from cache), + showing that the server re-renders the component. +

    +

    + 📍 Bottom marker - Use this to test scroll restoration! +

    +
    ) } diff --git a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx index e5ee169c9..43756d853 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -37,6 +37,111 @@ export function HomePage() { +
    +

    Section 1: Understanding React Server Components

    +

    + React Server Components (RSC) represent a new paradigm in React + applications. They allow you to write components that render on the + server and stream to the client, reducing bundle size and improving + initial load performance. +

    +

    + Unlike traditional server-side rendering, RSCs can be refetched + without a full page reload, enabling dynamic updates while maintaining + the benefits of server rendering. +

    +
    +
    +

    Section 2: Navigation Benefits

    +

    + This example showcases how coordinated navigation works with React + transitions. The key benefits include: +

    +
      +
    • Smooth transitions between pages without full page reloads
    • +
    • Intelligent caching of previously visited pages
    • +
    • Proper handling of browser back/forward buttons
    • +
    • Race condition prevention during rapid navigation
    • +
    • Loading state management during async transitions
    • +
    +
    +
    +

    Section 3: Performance Characteristics

    +

    + When you navigate to a page for the first time, the RSC payload is + fetched from the server. This payload is then cached in memory, + associated with the specific history entry. +

    +

    + On subsequent visits via back/forward navigation, the cached payload + is reused instantly, providing a near-instantaneous page transition. + This creates a seamless user experience similar to a traditional SPA + while maintaining the benefits of server rendering. +

    +
    +
    +

    Section 4: Testing Scroll Restoration

    +

    + Scroll down this page, then navigate to another page using the links + in the header. After that, use your browser's back button to return + here. +

    +

    + Notice how the browser automatically restores your scroll position! + This is native browser behavior that works seamlessly with our + navigation coordination. +

    +
    +
    +

    Section 5: Implementation Details

    +

    + The implementation uses React's startTransition API to + coordinate navigation updates. This ensures that URL changes and + content updates happen in sync, preventing jarring UI jumps or + inconsistent states. +

    +

    + The cache is implemented as a simple Map structure, keyed by history + state IDs. Each navigation creates a unique state ID that persists + across back/forward navigation, enabling reliable cache lookups. +

    +
    +
    +

    Section 6: Browser History Integration

    +

    + Modern browsers provide sophisticated history management APIs. Our + implementation leverages these APIs to create a seamless navigation + experience that feels native while using React Server Components. +

    +

    + The popstate event handler ensures that back/forward + navigation is properly detected and handled, coordinating with React's + rendering cycle to provide smooth transitions. +

    +
    +
    +

    Section 7: Future Enhancements

    +

    This example can be extended with additional features such as:

    +
      +
    • Prefetching pages on link hover for even faster navigation
    • +
    • Cache size limits and eviction strategies
    • +
    • Stale-while-revalidate patterns for background updates
    • +
    • Optimistic UI updates during navigation
    • +
    • Progress indicators for slow network conditions
    • +
    +
    +
    +

    Section 8: Bottom of Page

    +

    + You've reached the bottom! Now try navigating to the About page or + Slow Page, then use the browser back button to see scroll restoration + in action. +

    +

    + 📍 Scroll position marker - You can use this to verify that your + scroll position is restored when navigating back to this page. +

    +
    ) } diff --git a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx index a41a5bec9..af6db0558 100644 --- a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx +++ b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx @@ -50,6 +50,123 @@ export async function SlowPage(props: { url: URL }) { {new Date().toLocaleTimeString()}

    +
    +

    Section 1: Simulating Network Conditions

    +

    + This page intentionally delays its response to simulate slow network + conditions or heavy server-side processing. In real applications, you + might encounter similar delays when: +

    +
      +
    • Fetching data from slow external APIs
    • +
    • Running complex database queries
    • +
    • Processing large amounts of data on the server
    • +
    • Dealing with high server load
    • +
    +
    +
    +

    Section 2: Transition Coordination

    +

    + During the loading period, React's transition system keeps the current + page visible while preparing the new one. This prevents showing a + blank screen or jarring layout shifts. +

    +

    + The "pending..." indicator in the navigation shows that a transition + is in progress, giving users clear feedback about the application + state. +

    +
    +
    +

    Section 3: Race Condition Prevention

    +

    + Try clicking rapidly between different delay options. Notice that even + if you click multiple links quickly, the navigation system properly + handles the race conditions. +

    +

    + The most recent navigation always wins, and previous pending + navigations are automatically cancelled. This prevents outdated + content from appearing after a newer navigation has started. +

    +
    +
    +

    Section 4: Cache Behavior with Slow Pages

    +

    + Here's something interesting: Even though this page takes time to load + initially, once it's cached, it loads instantly when you navigate back + using the browser back button. +

    +

    + Try it: navigate to another page, then click back. The previously slow + page now appears immediately because it's served from cache! +

    +
    +
    +

    Section 5: Loading State Management

    +

    + The loading state is managed at the framework level, coordinating + between: +

    +
      +
    • The URL state (updates immediately)
    • +
    • The visual loading indicator (shows during fetch)
    • +
    • The content transition (waits for data)
    • +
    • The history entry (created at the right time)
    • +
    +

    + This coordination ensures a consistent user experience even with + varying network conditions. +

    +
    +
    +

    Section 6: User Experience Patterns

    +

    + In production applications, you might want to add additional UX + enhancements for slow loading scenarios: +

    +
      +
    • Skeleton screens to show expected layout
    • +
    • Progress bars for long operations
    • +
    • Cancel buttons for user-initiated aborts
    • +
    • Timeout handling with retry mechanisms
    • +
    • Offline detection and appropriate messaging
    • +
    +
    +
    +

    Section 7: Scroll Position Testing

    +

    + Scroll down to this section and note your position. Then navigate away + and come back using the browser back button. +

    +

    + Even though this page initially took time to load, when you return via + back navigation, it not only loads instantly from cache but also + restores your exact scroll position! +

    +
    +
    +

    Section 8: Performance Optimization

    +

    In real applications, you'd want to optimize slow operations by:

    +
      +
    • Using database indexes for faster queries
    • +
    • Implementing server-side caching (Redis, Memcached)
    • +
    • Optimizing API calls with batching or GraphQL
    • +
    • Using CDNs for static assets
    • +
    • Implementing request deduplication
    • +
    +
    +
    +

    Section 9: Bottom of Slow Page

    +

    + You've scrolled to the bottom! The timestamp above shows when this + page was initially loaded. Try different delay values to see how the + system handles various loading times. +

    +

    + 📍 End marker - Perfect spot to test scroll restoration! +

    +
    ) } From 7c7916f5b6915e7694224d4c1b8761ad8a7f0f16 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 21 Nov 2025 09:30:46 +0900 Subject: [PATCH 29/30] wip --- .../examples/navigation/package.json | 1 + .../src/framework/entry.browser.tsx | 182 ++++++++++++++++-- .../navigation/src/framework/lib/link.tsx | 0 .../navigation/src/framework/lib/router.tsx | 0 .../navigation/src/framework/router.tsx | 14 ++ pnpm-lock.yaml | 9 + 6 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/lib/link.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/lib/router.tsx create mode 100644 packages/plugin-rsc/examples/navigation/src/framework/router.tsx diff --git a/packages/plugin-rsc/examples/navigation/package.json b/packages/plugin-rsc/examples/navigation/package.json index 7fca5b163..d2d6b1db0 100644 --- a/packages/plugin-rsc/examples/navigation/package.json +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -14,6 +14,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@tanstack/history": "^1.133.28", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "latest", 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 676752875..24db47fec 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -12,38 +12,180 @@ import type { RscPayload } from './entry.rsc' import { NavigationManager, type NavigationState } from './navigation' import { GlobalErrorBoundary } from './error-boundary' import { createRscRenderRequest } from './request' +import { RouterContext, type RouterContextType } from './router' +// import { createBrowserHistory } from "@tanstack/history" + +type NavigationEntry = { + url: string + // cached page payload + // TODO: reuse for back/forward navigation. invalidate on server function. + data: Promise + // update browser url on commit + flush: () => void +} + +// https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 +// https://github.com/rickhanlonii/async-react/blob/c971b2fa57785dc2014251ec90d3c18cb7a958c6/src/router/index.jsx#L114-L116 +function FlushNavigationEntry({ entry }: { entry: NavigationEntry }) { + React.useInsertionEffect(() => { + // ensure it flushed once for any entry (e.g. strict mode) + entry.flush() + entry.flush = () => {} + }, [entry]) + + return null +} + +function RenderNavigationEntry({ entry }: { entry: NavigationEntry }) { + return React.use(entry.data).root +} + +class NavigationEntryManager { + // TODO + // entries for navigation stack + // TODO + // dispatch(url: string) {} +} async function main() { const initialPayload = await createFromReadableStream(rscStream) - const manager = new NavigationManager(initialPayload) + // const browserHistory = createBrowserHistory(); + // browserHistory.go; + // browserHistory.subscribe + // const manager = new NavigationManager(initialPayload) + + // function Router(props: React.PropsWithChildren) { + // const routerContext: RouterContextType = { + // url: window.location.href, + // navigate: (to: string, options?: { replace?: boolean }) => { + // }, + // } + // const routeState = { + // url: window.location.href, + // commit: () => {}, + // }; + // // React.useOptimistic; + // // optimisticUrl + + // return ( + // + // {props.children} + // + // ) + // } + + const initialEntry: NavigationEntry = { + url: window.location.href, + data: Promise.resolve(initialPayload), + flush: () => {}, + } function BrowserRoot() { - const [state, setState] = React.useState(manager.getState()) + const [currentEntry, setCurrentEntry] = + React.useState(initialEntry) + // const [isPending, setIsPending] = React.useOptimistic(false); const [isPending, startTransition] = React.useTransition() + // 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(() => { + // manager.setReactHandlers(setState, startTransition) + // return manager.listen() + // }, []) + React.useEffect(() => { - manager.setReactHandlers(setState, startTransition) - return manager.listen() + const handleAnchorClick = (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() + startTransition(() => { + setCurrentEntry({ + url: link.href, + data: createFromFetch( + fetch(createRscRenderRequest(link.href)), + ), + flush: () => { + window.history.pushState({}, '', link.href) + }, + }) + }) + } + } + + document.addEventListener('click', handleAnchorClick) + + // Handle popstate (back/forward) + function handlePopstate(e: PopStateEvent) { + // TODO: use state key from event to look up cache + // e.state.key + e + // this.navigate(window.location.href) + } + + window.addEventListener('popstate', handlePopstate) + + // Cleanup + return () => { + document.removeEventListener('click', handleAnchorClick) + window.removeEventListener('popstate', handlePopstate) + } }, []) return ( <> - {state.push && } - + + ) } + // function BrowserRoot() { + // 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(() => { + // // manager.setReactHandlers(setState, startTransition) + // // return manager.listen() + // // }, []) + + // React.useEffect(() => { + // browserHistory.subscribe; + // }, []) + + // return ( + // <> + // {state.push && } + // {/* */} + // + // + // ) + // } + // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 - function HistoryUpdater({ url }: { url: string }) { - React.useInsertionEffect(() => { - manager.commitHistoryPush(url) - }, [url]) - return null - } + // function HistoryUpdater({ url }: { url: string }) { + // React.useInsertionEffect(() => { + // manager.commitHistoryPush(url) + // }, [url]) + // return null + // } function TransitionStatus(props: { isPending: boolean }) { React.useEffect(() => { @@ -52,7 +194,7 @@ async function main() { el = document.createElement('div') el.id = 'pending' el.style.position = 'fixed' - el.style.bottom = '10px' + el.style.top = '10px' el.style.right = '10px' el.style.padding = '8px 16px' el.style.backgroundColor = 'rgba(0, 0, 0, 0.8)' @@ -76,10 +218,10 @@ async function main() { return null } - function RenderState({ state }: { state: NavigationState }) { - const payload = React.use(state.payloadPromise) - return payload.root - } + // function RenderState({ state }: { state: NavigationState }) { + // const payload = React.use(state.payloadPromise) + // return payload.root + // } setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet() @@ -90,7 +232,7 @@ async function main() { const payload = await createFromFetch(fetch(renderRequest), { temporaryReferences, }) - manager.handleServerAction(payload) + // manager.handleServerAction(payload) const { ok, data } = payload.returnValue! if (!ok) throw data return data @@ -114,8 +256,8 @@ async function main() { if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { - manager.invalidateCache() - manager.navigate(window.location.href) + // manager.invalidateCache() + // manager.navigate(window.location.href) }) } } diff --git a/packages/plugin-rsc/examples/navigation/src/framework/lib/link.tsx b/packages/plugin-rsc/examples/navigation/src/framework/lib/link.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-rsc/examples/navigation/src/framework/lib/router.tsx b/packages/plugin-rsc/examples/navigation/src/framework/lib/router.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-rsc/examples/navigation/src/framework/router.tsx b/packages/plugin-rsc/examples/navigation/src/framework/router.tsx new file mode 100644 index 000000000..8e720c2e4 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/router.tsx @@ -0,0 +1,14 @@ +import { createContext, use } from 'react' + +export type RouterContextType = { + url: string + navigate: (to: string, options?: { replace?: boolean }) => void +} + +export const RouterContext = createContext(undefined!) + +export function useRouter() { + return use(RouterContext) +} + +function createRouter() {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36502701d..044bd8fc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -651,6 +651,9 @@ importers: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) devDependencies: + '@tanstack/history': + specifier: ^1.133.28 + version: 1.133.28 '@types/react': specifier: ^19.2.2 version: 19.2.5 @@ -2218,6 +2221,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/history@1.133.28': + resolution: {integrity: sha512-B7+x7eP2FFvi3fgd3rNH9o/Eixt+pp0zCIdGhnQbAJjFrlwIKGjGnwyJjhWJ5fMQlGks/E2LdDTqEV4W9Plx7g==} + engines: {node: '>=12'} + '@tsconfig/strictest@2.0.8': resolution: {integrity: sha512-XnQ7vNz5HRN0r88GYf1J9JJjqtZPiHt2woGJOo2dYqyHGGcd6OLGqSlBB6p1j9mpzja6Oe5BoPqWmeDx6X9rLw==} @@ -5616,6 +5623,8 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + '@tanstack/history@1.133.28': {} + '@tsconfig/strictest@2.0.8': {} '@tybys/wasm-util@0.10.1': From 87e47ae887a45bb8a9cadf158282291b6c44db1b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 21 Nov 2025 19:14:58 +0900 Subject: [PATCH 30/30] tweak --- .../src/framework/entry.browser.tsx | 53 ++++++++++++------- .../examples/navigation/src/root.tsx | 23 ++++++-- 2 files changed, 55 insertions(+), 21 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 24db47fec..933c08f75 100644 --- a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -9,10 +9,10 @@ import React from 'react' import { createRoot, hydrateRoot } from 'react-dom/client' import { rscStream } from 'rsc-html-stream/client' import type { RscPayload } from './entry.rsc' -import { NavigationManager, type NavigationState } from './navigation' +// import { NavigationManager, type NavigationState } from './navigation' import { GlobalErrorBoundary } from './error-boundary' import { createRscRenderRequest } from './request' -import { RouterContext, type RouterContextType } from './router' +// import { RouterContext, type RouterContextType } from './router' // import { createBrowserHistory } from "@tanstack/history" type NavigationEntry = { @@ -40,12 +40,12 @@ function RenderNavigationEntry({ entry }: { entry: NavigationEntry }) { return React.use(entry.data).root } -class NavigationEntryManager { - // TODO - // entries for navigation stack - // TODO - // dispatch(url: string) {} -} +// class NavigationEntryManager { +// // TODO +// // entries for navigation stack +// // TODO +// // dispatch(url: string) {} +// } async function main() { const initialPayload = await createFromReadableStream(rscStream) @@ -81,8 +81,24 @@ async function main() { flush: () => {}, } + let setCurrentEntry: React.Dispatch> + + function navigate(url: string, options?: { replace?: boolean }) { + setCurrentEntry({ + url, + data: createFromFetch(fetch(createRscRenderRequest(url))), + flush: () => { + if (options?.replace) { + window.history.replaceState({}, '', url) + } else { + window.history.pushState({}, '', url) + } + }, + }) + } + function BrowserRoot() { - const [currentEntry, setCurrentEntry] = + const [currentEntry, setCurrentEntry_] = React.useState(initialEntry) // const [isPending, setIsPending] = React.useOptimistic(false); const [isPending, startTransition] = React.useTransition() @@ -97,6 +113,8 @@ async function main() { // }, []) React.useEffect(() => { + setCurrentEntry = setCurrentEntry_ + const handleAnchorClick = (e: MouseEvent) => { const link = (e.target as Element).closest('a') if ( @@ -115,15 +133,7 @@ async function main() { ) { e.preventDefault() startTransition(() => { - setCurrentEntry({ - url: link.href, - data: createFromFetch( - fetch(createRscRenderRequest(link.href)), - ), - flush: () => { - window.history.pushState({}, '', link.href) - }, - }) + navigate(link.href) }) } } @@ -187,6 +197,7 @@ async function main() { // return null // } + // TODO: expose `isPending` via store / context function TransitionStatus(props: { isPending: boolean }) { React.useEffect(() => { let el = document.querySelector('#pending') as HTMLDivElement @@ -235,6 +246,11 @@ async function main() { // manager.handleServerAction(payload) const { ok, data } = payload.returnValue! if (!ok) throw data + setCurrentEntry({ + url: window.location.href, + data: Promise.resolve(payload), + flush: () => {}, + }) return data }) @@ -258,6 +274,7 @@ async function main() { import.meta.hot.on('rsc:update', () => { // manager.invalidateCache() // manager.navigate(window.location.href) + navigate(window.location.href, { replace: true }) }) } } diff --git a/packages/plugin-rsc/examples/navigation/src/root.tsx b/packages/plugin-rsc/examples/navigation/src/root.tsx index ec76e1e07..906f0540c 100644 --- a/packages/plugin-rsc/examples/navigation/src/root.tsx +++ b/packages/plugin-rsc/examples/navigation/src/root.tsx @@ -4,7 +4,7 @@ import { AboutPage } from './routes/about' import { SlowPage } from './routes/slow' export function Root(props: { url: URL }) { - const pathname = props.url.pathname + const { pathname, searchParams } = props.url let page: React.ReactNode let title = 'Navigation Example' @@ -42,8 +42,25 @@ export function Root(props: { url: URL }) { > About - - Slow Page + + Slow Page (1s) + + + Slow Page (2s)