Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bde18b7
feat(plugin-rsc): add navigation example demonstrating coordinated hi…
claude Oct 22, 2025
55d26f2
feat(plugin-rsc): add back/forward cache to navigation example
claude Oct 22, 2025
ec8e16e
refactor(plugin-rsc): consolidate navigation logic into Router class
claude Oct 22, 2025
a07a299
chore(plugin-rsc): cleanup navigation example config
claude Oct 22, 2025
4424912
chore(plugin-rsc): remove vite-plugin-inspect from navigation example
claude Oct 22, 2025
259c481
refactor(plugin-rsc): move Router to separate file
claude Oct 22, 2025
f7a91c0
refactor(plugin-rsc): rename Router to NavigationManager
claude Oct 22, 2025
729017e
feat(plugin-rsc): add Navigation API support to navigation example
claude Oct 22, 2025
54a38f5
docs(plugin-rsc): update README with Navigation API examples
claude Oct 22, 2025
3a7fab8
Merge branch 'main' into claude/investigate-vite-rsc-issue-011CUN8yUf…
hi-ogawa Oct 23, 2025
3b1b582
chore: deps
hi-ogawa Oct 23, 2025
bdb060b
chore: update example
hi-ogawa Oct 23, 2025
74df4cb
cleanup
hi-ogawa Oct 23, 2025
2066f18
chore: remove navigation api
hi-ogawa Oct 23, 2025
1bb2720
Merge branch 'main' into claude/investigate-vite-rsc-issue-011CUN8yUf…
hi-ogawa Oct 28, 2025
b6efc46
cleanup
hi-ogawa Oct 28, 2025
1c2f345
refactor(plugin-rsc): remove comments from navigation example entry f…
hi-ogawa Oct 28, 2025
54d8419
comment
hi-ogawa Oct 29, 2025
1dfb033
comment
hi-ogawa Oct 29, 2025
79c71db
cleanup
hi-ogawa Oct 29, 2025
58c5201
cleanup
hi-ogawa Oct 29, 2025
d66560e
cleanup
hi-ogawa Oct 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions packages/plugin-rsc/examples/navigation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Navigation Example - Coordinating History, Transitions, and Caching

TODO: review

This example demonstrates how to properly coordinate Browser URL update with React transitions and implement instant back/forward navigation via caching in a React Server Components application.

## Problem

In a typical RSC application with client-side navigation, there's a challenge in coordinating:

1. Browser history changes (pushState/replaceState/popstate)
2. React transitions for smooth updates
3. Asynchronous data fetching
4. Loading state indicators
5. Back/forward navigation performance

Without proper coordination, you can encounter:

- URL bar being out of sync with rendered content
- Slow back/forward navigation (refetching from server)
- Issues with cache invalidation after mutations
- Missing or inconsistent loading indicators

## Solution

This example implements a caching pattern that addresses these issues:

### Key Concepts

1. **Modern Navigation API**: Uses [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) when available, falls back to History API
2. **Back/Forward Cache by Entry**: Each navigation entry gets a unique key, cache maps `key → Promise<RscPayload>`
3. **Instant Navigation**: Cache hits render synchronously (no loading state), cache misses show transitions
4. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions
5. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()`
6. **Cache Invalidation**: Server actions update cache for current entry

### Browser Compatibility

The implementation automatically detects and uses:

- **Navigation API** (Chrome 102+, Edge 102+): Modern, cleaner API with built-in entry keys
- **History API** (all browsers): Fallback for older browsers, requires manual key management

No configuration needed - feature detection happens automatically!

### Implementation

The core implementation is in `src/framework/navigation.ts`:

```typescript
// Feature detection
const supportsNavigationAPI = 'navigation' in window

// Navigation API: Clean, modern
private listenNavigationAPI(): () => void {
const onNavigate = (e: NavigateEvent) => {
if (!e.canIntercept) return

e.intercept({
handler: async () => {
this.navigate(url.href)
},
})
}
window.navigation.addEventListener('navigate', onNavigate)
return () => window.navigation.removeEventListener('navigate', onNavigate)
}

// History API fallback: Works everywhere
private listenHistoryAPI(): () => void {
window.history.pushState = (...args) => {
args[0] = this.addStateKey(args[0])
this.oldPushState.apply(window.history, args)
this.navigate(url.href)
}
// ... popstate, replaceState, link clicks
}

// Dispatch coordinates navigation with transitions and cache
dispatch = (action: NavigationAction) => {
startTransition(() => {
setState_({
url: action.url,
push: action.push,
payloadPromise: action.payload
? Promise.resolve(action.payload)
: bfCache.run(() => createFromFetch<RscPayload>(fetch(action.url))),
})
})
}

// Each history entry gets a unique key
function addStateKey(state: any): HistoryState {
const key = Math.random().toString(36).slice(2)
return { ...state, key }
}
```

**Why this works:**

- `React.use()` can unwrap both promises AND resolved values
- Cache hit → returns existing promise → `React.use()` unwraps synchronously → instant render, no transition!
- Cache miss → creates new fetch promise → `React.use()` suspends → shows loading, transition active
- Browser automatically handles scroll restoration via proper history state

## Running the Example

```bash
pnpm install
pnpm dev
```

Then navigate to http://localhost:5173

## What to Try

1. **Cache Behavior**:
- Visit "Slow Page" (notice the loading indicator)
- Navigate to another page
- Click browser back button
- Notice: No loading indicator! Instant render from cache

2. **Cache Miss vs Hit**:
- First visit to any page shows "loading..." (cache miss)
- Back/forward to visited pages is instant (cache hit)
- Even slow pages are instant on second visit

3. **Server Actions**:
- Go to "Counter Page" and increment server counter
- Notice the cache updates for current entry
- Navigate away and back to see updated state

4. **Scroll Restoration**: Browser handles this automatically via proper history state

## References

This pattern is inspired by:

- [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) - Modern navigation standard
- [hi-ogawa/vite-environment-examples](https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server/src/features/router/browser.ts) - Back/forward cache implementation
- [TanStack Router](https://github.com/TanStack/router/blob/main/packages/history/src/index.ts) - History state key pattern
- [React useTransition](https://react.dev/reference/react/useTransition)
- [React.use](https://react.dev/reference/react/use)

## Related

- GitHub Issue: https://github.com/vitejs/vite-plugin-react/issues/860
- Reproduction: https://github.com/hi-ogawa/reproductions/tree/main/vite-rsc-coordinate-history-and-transition
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/navigation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@vitejs/plugin-rsc-examples-navigation",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^7.1.10"
}
}
1 change: 1 addition & 0 deletions packages/plugin-rsc/examples/navigation/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
createFromReadableStream,
createFromFetch,
setServerCallback,
createTemporaryReferenceSet,
encodeReply,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { NavigationManager, type NavigationState } from './navigation'

async function main() {
const initialPayload = await createFromReadableStream<RscPayload>(rscStream)

const manager = new NavigationManager(initialPayload)

function BrowserRoot() {
const [state, setState] = React.useState(manager.getState())
const [isPending, startTransition] = React.useTransition()

// https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/use-action-queue.ts#L49
React.useEffect(() => {
manager.setReactHandlers(setState, startTransition)
return manager.listen()
}, [])

return (
<>
{state.push && <HistoryUpdater url={state.url} />}
<TransitionStatus isPending={isPending} />
<RenderState state={state} />
</>
)
}

// https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96
function HistoryUpdater({ url }: { url: string }) {
React.useInsertionEffect(() => {
manager.commitHistoryPush(url)
}, [url])
return null
}

function TransitionStatus(props: { isPending: boolean }) {
React.useEffect(() => {
let el = document.querySelector('#pending') as HTMLDivElement
if (!el) {
el = document.createElement('div')
el.id = 'pending'
el.style.position = 'fixed'
el.style.bottom = '10px'
el.style.right = '10px'
el.style.padding = '8px 16px'
el.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'
el.style.color = 'white'
el.style.borderRadius = '4px'
el.style.fontSize = '14px'
el.style.fontFamily = 'monospace'
el.style.transition = 'opacity 0.3s ease-in-out'
el.style.pointerEvents = 'none'
el.style.zIndex = '9999'
document.body.appendChild(el)
}

if (props.isPending) {
el.textContent = 'loading...'
el.style.opacity = '1'
} else {
el.style.opacity = '0'
}
}, [props.isPending])
return null
}

function RenderState({ state }: { state: NavigationState }) {
const payload = React.use(state.payloadPromise)
return payload.root
}

setServerCallback(async (id, args) => {
const url = new URL(window.location.href)
const temporaryReferences = createTemporaryReferenceSet()
const payload = await createFromFetch<RscPayload>(
fetch(url, {
method: 'POST',
body: await encodeReply(args, { temporaryReferences }),
headers: {
'x-rsc-action': id,
},
}),
{ temporaryReferences },
)
manager.handleServerAction(payload)
return payload.returnValue
})

hydrateRoot(
document,
<React.StrictMode>
<BrowserRoot />
</React.StrictMode>,
{ formState: initialPayload.formState },
)

if (import.meta.hot) {
import.meta.hot.on('rsc:update', () => {
manager.invalidateCache()
manager.navigate(window.location.href)
})
}
}

main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
renderToReadableStream,
createTemporaryReferenceSet,
decodeReply,
loadServerAction,
decodeAction,
decodeFormState,
} from '@vitejs/plugin-rsc/rsc'
import type { ReactFormState } from 'react-dom/client'
import { Root } from '../root.tsx'

export type RscPayload = {
root: React.ReactNode
returnValue?: unknown
formState?: ReactFormState
}

export default async function handler(request: Request): Promise<Response> {
const isAction = request.method === 'POST'
let returnValue: unknown | undefined
let formState: ReactFormState | undefined
let temporaryReferences: unknown | undefined
if (isAction) {
const actionId = request.headers.get('x-rsc-action')
if (actionId) {
const contentType = request.headers.get('content-type')
const body = contentType?.startsWith('multipart/form-data')
? await request.formData()
: await request.text()
temporaryReferences = createTemporaryReferenceSet()
const args = await decodeReply(body, { temporaryReferences })
const action = await loadServerAction(actionId)
returnValue = await action.apply(null, args)
} else {
const formData = await request.formData()
const decodedAction = await decodeAction(formData)
const result = await decodedAction()
formState = await decodeFormState(result, formData)
}
}

const url = new URL(request.url)
const rscPayload: RscPayload = {
root: <Root url={url} />,
formState,
returnValue,
}
const rscOptions = { temporaryReferences }
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions)

const isRscRequest =
(!request.headers.get('accept')?.includes('text/html') &&
!url.searchParams.has('__html')) ||
url.searchParams.has('__rsc')

if (isRscRequest) {
return new Response(rscStream, {
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
},
})
}

const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr.tsx')
>('ssr', 'index')
const htmlStream = await ssrEntryModule.renderHTML(rscStream, {
formState,
debugNojs: url.searchParams.has('__nojs'),
})

return new Response(htmlStream, {
headers: {
'Content-type': 'text/html',
vary: 'accept',
},
})
}

if (import.meta.hot) {
import.meta.hot.accept()
}
Loading
Loading