+ )
+}
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:
+
+
+ Each history.state gets a unique random{' '}
+ key
+
+
+ Cache maps key → Promise<RscPayload>
+
+
On navigation, check if current history state key is in cache
+ Cache miss → fetch from server → shows loading state → cache result
+
+
+
+ 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 `
{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:
-
-
Increment both counters
-
Navigate to another page
-
Navigate back to this page
-
- Notice that the client counter resets but the server counter
- persists
-
- Cache miss → fetch from server → shows loading state → cache result
-
-
-
- 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!
+
+ 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.
+
+ 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:
+ 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!
+