diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md new file mode 100644 index 000000000..63505f803 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -0,0 +1,148 @@ +# Navigation Example - Coordinating History, Transitions, and Caching + +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 + +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 +5. Back/forward navigation performance + +Without proper coordination, you can encounter: + +- URL bar being out of sync with rendered content +- Slow back/forward navigation (refetching from server) +- Issues with cache invalidation after mutations +- Missing or inconsistent loading indicators + +## Solution + +This example implements a caching pattern that addresses these issues: + +### Key Concepts + +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/navigation.ts`: + +```typescript +// 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) +} + +// 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 +dispatch = (action: NavigationAction) => { + startTransition(() => { + setState_({ + url: action.url, + push: action.push, + payloadPromise: action.payload + ? Promise.resolve(action.payload) + : bfCache.run(() => createFromFetch(fetch(action.url))), + }) + }) +} + +// 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 +pnpm install +pnpm dev +``` + +Then navigate to http://localhost:5173 + +## What to Try + +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 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) +- [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..7fca5b163 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -0,0 +1,24 @@ +{ + "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": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@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.1.10" + } +} 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..943bd79d3 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -0,0 +1,115 @@ +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' +import { NavigationManager, type NavigationState } from './navigation' + +async function main() { + const initialPayload = await createFromReadableStream(rscStream) + + const manager = new NavigationManager(initialPayload) + + 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() + }, []) + + 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 TransitionStatus(props: { isPending: boolean }) { + React.useEffect(() => { + let el = document.querySelector('#pending') as HTMLDivElement + if (!el) { + el = document.createElement('div') + el.id = '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.textContent = 'loading...' + el.style.opacity = '1' + } else { + el.style.opacity = '0' + } + }, [props.isPending]) + return null + } + + function RenderState({ state }: { state: NavigationState }) { + const payload = React.use(state.payloadPromise) + return payload.root + } + + 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 }, + ) + manager.handleServerAction(payload) + return payload.returnValue + }) + + hydrateRoot( + document, + + + , + { formState: initialPayload.formState }, + ) + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + manager.invalidateCache() + manager.navigate(window.location.href) + }) + } +} + +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..9baec56fe --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -0,0 +1,83 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} 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 { + 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 = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const url = new URL(request.url) + const rscPayload: RscPayload = { + 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) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + 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..8c2c4d531 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -0,0 +1,48 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +) { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + return {React.use(payload).root} + } + + function FixSsrThenable(props: React.PropsWithChildren) { + return props.children + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return responseStream +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts new file mode 100644 index 000000000..8c87a9636 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -0,0 +1,229 @@ +import { createFromFetch } from '@vitejs/plugin-rsc/browser' +import type { RscPayload } from './entry.rsc' + +// 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 + payloadPromise: Promise +} + +/** + * History state with unique key per entry (History API fallback) + */ +type HistoryState = null | { + key?: string +} + +/** + * Navigation manager + * Encapsulates all navigation logic: history interception, caching, transitions + */ +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 + + 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('NavigationManager 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) + * Only needed for History API fallback + */ + commitHistoryPush(url: string) { + this.state.push = false + this.oldPushState.call(window.history, this.addStateKey({}), '', url) + } + + /** + * Setup navigation interception and listeners + */ + listen(): () => void { + // Intercept pushState + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + 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 + } + + // Intercept replaceState + window.history.replaceState = (...args) => { + args[0] = this.addStateKey(args[0]) + 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) + } + + // Handle popstate (back/forward) + 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) + + // 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 (History API only) + */ + private initializeHistoryState() { + if (!(window.history.state as HistoryState)?.key) { + this.oldReplaceState.call( + window.history, + this.addStateKey(window.history.state), + '', + window.location.href, + ) + } + } + + // 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 } + } +} + +/** + * 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 = this.getCurrentKey() + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) + } + return fn() + } + + set(value: T | undefined) { + const key = this.getCurrentKey() + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } + } + + /** + * Get current entry key + * Uses Navigation API when available, falls back to History API + */ + private getCurrentKey(): string | undefined { + return (window.history.state as HistoryState)?.key + } +} 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..85ef7939d --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -0,0 +1,248 @@ +: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; +} + +.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; + 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 { + display: flex; + justify-content: center; + max-width: 1200px; + margin: 0 auto; + 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..ec76e1e07 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/root.tsx @@ -0,0 +1,55 @@ +import './index.css' +import { HomePage } from './routes/home' +import { AboutPage } from './routes/about' +import { SlowPage } from './routes/slow' + +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 { + 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/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx new file mode 100644 index 000000000..f95a4df98 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -0,0 +1,123 @@ +export function HomePage() { + return ( +
+

Home Page

+

+ 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

+

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

+
+
+ ) +} 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..a41a5bec9 --- /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')) || 500 + + // 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..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "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" + } +} 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..a8ab7440f --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/vite.config.ts @@ -0,0 +1,20 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +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', + }, + }), + ], + build: { + minify: false, + }, +}) as any diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e159f540..dc1daf6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -642,6 +642,34 @@ importers: specifier: ^3.0.2 version: 3.0.2 + packages/plugin-rsc/examples/navigation: + dependencies: + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + 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.1.10 + 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: react: