Skip to content

chore(rsc): add example of coordinating browser URL update with React transition#939

Draft
hi-ogawa wants to merge 34 commits intovitejs:mainfrom
hi-ogawa:claude/investigate-vite-rsc-issue-011CUN8yUfZbB7gGEXqY8hUt
Draft

chore(rsc): add example of coordinating browser URL update with React transition#939
hi-ogawa wants to merge 34 commits intovitejs:mainfrom
hi-ogawa:claude/investigate-vite-rsc-issue-011CUN8yUfZbB7gGEXqY8hUt

Conversation

@hi-ogawa
Copy link
Contributor

@hi-ogawa hi-ogawa commented Oct 22, 2025

TODO

References

…story and transitions

Add a new example that demonstrates how to properly coordinate browser
history navigation with React transitions in RSC applications.

Key features:
- Dispatch-based navigation coordination pattern
- History updates via useInsertionEffect after state updates
- Promise-based navigation state with React.use()
- Visual feedback with transition status indicator
- Prevents race conditions with rapid navigation
- Proper back/forward navigation support

This pattern is inspired by Next.js App Router implementation and
addresses common issues with client-side navigation in RSC apps:
- URL bar staying in sync with rendered content
- Proper loading state management
- Race condition prevention
- Coordinated back/forward navigation

Based on: https://github.com/hi-ogawa/reproductions/tree/main/vite-rsc-coordinate-history-and-transition
Related to: vitejs#860

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Implement instant back/forward navigation using history-state-keyed cache:

- Cache maps history.state.key → Promise<RscPayload>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Remove vite-plugin-inspect dependency and usage to simplify the example.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@hi-ogawa hi-ogawa changed the title feat(plugin-rsc): add navigation example with coordinated history and transition chore(rsc): add navigation example with coordinated history and transition Oct 29, 2025
@hi-ogawa hi-ogawa changed the title chore(rsc): add navigation example with coordinated history and transition chore(rsc): add example of coordinating browser URL update with React transition Oct 29, 2025
@teddybradford
Copy link

teddybradford commented Feb 10, 2026

@hi-ogawa I spent some time playing around with adding Navigation API to the RSC SSG demo. I got scroll restoration working with one small issue that I'm not sure how to work around: when navigating forward/back between pages with different scroll positions, there's a brief moment where the scrollbar jumps to the top of the page before the new payload is replaced in the DOM.

I figured I'd post what I had in case it's of any interest to you or others.

./src/framework/entry.browser.tsx

import {
  createFromFetch,
  createFromReadableStream,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import { GlobalErrorBoundary } from './error-boundary'
import { createRscRenderRequest } from './request'
import type { RscPayload } from './shared'

async function hydrate(): Promise<void> {
  async function onNavigation(url: string) {
    const renderRequest = createRscRenderRequest(url)
    const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
    setPayload(payload)
  }

  const initialPayload = await createFromReadableStream<RscPayload>(rscStream)

  let setPayload: (v: RscPayload) => void

  // disable auto scroll restoration
  history.scrollRestoration = 'manual'

  // set initial state
  navigation.updateCurrentEntry({
    state: {
      scrollX: 0,
      scrollY: 0,
      ...navigation.currentEntry.getState(),
    },
  })

  function BrowserRoot() {
    const [payload, setPayload_] = React.useState(initialPayload)

    React.useEffect(() => {
      setPayload = (v) => React.startTransition(() => setPayload_(v))
    }, [setPayload_])

    React.useEffect(() => {
      const state = navigation.currentEntry.getState()
      if (state) {
        scrollTo(state.scrollX ?? 0, state.scrollY ?? 0)
      }
    }, [payload])

    // set up
    React.useEffect(() => {
      return listenNavigation((url) => {
        onNavigation(url)
      })
    }, [])

    return payload.root
  }

  const browserRoot = (
    <React.StrictMode>
      <GlobalErrorBoundary>
        <BrowserRoot />
      </GlobalErrorBoundary>
    </React.StrictMode>
  )

  if ('__NO_HYDRATE' in globalThis) {
    createRoot(document).render(browserRoot)
  } else {
    hydrateRoot(document, browserRoot)
  }

  if (import.meta.hot) {
    import.meta.hot.on('rsc:update', () => {
      navigation.navigate(navigation.currentEntry.url)
    })
  }
}

function listenNavigation(onNavigation: (url: string) => void): () => void {
  function onNavigate(event: NavigationEvent) {
    navigation.updateCurrentEntry({
      state: { ...navigation.currentEntry.getState(), scrollX, scrollY },
    })

    if (!event.canIntercept) {
      return
    }

    event.intercept({
      async handler() {
        onNavigation(event.destination.url)
      },
    })
  }

  navigation.addEventListener('navigate', onNavigate)

  return () => {
    navigation.removeEventListener('navigate', onNavigate)
  }
}

hydrate()

@teddybradford
Copy link

I made a couple updates, namely removing the need for useState and just relying on navigation API entirely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

improve client side navigation example

3 participants