Skip to content

test(rsc): add navigation example #567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 33 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6e552c8
Copied sample over from vite-plugins
grahammendick Jul 13, 2025
4be80b7
chore: cleanup unused
hi-ogawa Jul 14, 2025
73efed8
fix: tweak deps optimization
hi-ogawa Jul 14, 2025
7afc7cf
chore: cleanup
hi-ogawa Jul 14, 2025
55d8b13
chore: cleanup
hi-ogawa Jul 14, 2025
6451c74
Overrode vite hydrate for custom nav/context
grahammendick Jul 14, 2025
d001dc0
Ignored ts error
grahammendick Jul 14, 2025
215c9ca
Removed redundant form state
grahammendick Jul 14, 2025
8977949
Renamed for clarity
grahammendick Jul 14, 2025
3716eba
Used jsx for simplicity
grahammendick Jul 14, 2025
af43b06
Tweaked format
grahammendick Jul 14, 2025
33a8401
Changed for consistency with other nav rsc samples
grahammendick Jul 14, 2025
c99ea1f
Implemented rsc fetch
grahammendick Jul 18, 2025
e522409
Changed for consistency
grahammendick Jul 18, 2025
bb532b4
Implemented hmr path
grahammendick Jul 18, 2025
f5fd71f
Moved for clarity
grahammendick Jul 18, 2025
fbe0bbf
Changed for consistency
grahammendick Jul 18, 2025
ca31fcc
Swapped for clarity
grahammendick Jul 18, 2025
de4dd35
Removed internal imports
grahammendick Jul 18, 2025
8c69e5e
Changed to match other navigation rsc samples
grahammendick Jul 18, 2025
846c737
Removed for simplicity
grahammendick Jul 18, 2025
d0a4b59
Prevented error for favicon/well-known requests
grahammendick Jul 18, 2025
c8bfd82
Removed redundant param
grahammendick Jul 18, 2025
8a8b362
Merge remote-tracking branch 'upstream/main' into navigation-vite
grahammendick Jul 21, 2025
f37e50f
Merge branch 'main' into navigation-vite
hi-ogawa Jul 22, 2025
3ad94b4
chore: rename example
hi-ogawa Jul 22, 2025
d654680
Merge branch 'navigation-vite' of https://github.com/grahammendick/vi…
grahammendick Jul 22, 2025
6d65d66
Copied latest from vite sample in navigation repo
grahammendick Jul 26, 2025
1961d19
Updated to latest navigation-react for hmr support
grahammendick Aug 1, 2025
7f5c462
Merge branch 'main' into navigation-vite
hi-ogawa Aug 2, 2025
515b51a
Created hmr example to show the problem
grahammendick Aug 2, 2025
2c1ddfa
Merge branch 'navigation-vite' of https://github.com/grahammendick/vi…
grahammendick Aug 2, 2025
d146648
Added instructions on how to repro the error
grahammendick Aug 3, 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
7 changes: 7 additions & 0 deletions packages/plugin-rsc/examples/hmr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
The `App` just renders a client `Item` component passing in a key which It gets from 'lookup.ts'. The `Item` passes this key into 'lookup.ts' to get the value which it then displays. If you edit the key in 'lookup.ts' from 'a' to 'b', for example, then the hmr update throws.

```ts
export const key = 'b'
```

The reason for the error is that the server response uses the latest 'lookup.ts' but the client uses the old 'lookup.ts'. So the rsc response has the key 'b' but the client still has the key 'a'.
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/hmr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@vitejs/plugin-rsc-examples-hmr",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-rsc": "latest",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^7.0.4",
"vite-plugin-inspect": "^11.3.0"
}
}
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { key } from './lookup'
import Item from './Item'

const App = async () => {
return (
<html>
<head>
<title>Hmr</title>
</head>
<body>
<Item k={key} />
</body>
</html>
)
}

export default App
8 changes: 8 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/Item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client'
import lookup from './lookup'

const Item = ({ k }: { k: string }) => {
return lookup(k).hello
}

export default Item
30 changes: 30 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as ReactClient from '@vitejs/plugin-rsc/browser'
import { useState, useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import { createFromFetch } from '@vitejs/plugin-rsc/browser'

async function fetchRSC(url: string, options: any) {
const payload = (await createFromFetch(fetch(url, options))) as any
return payload.root
}

const initialPayload = await ReactClient.createFromReadableStream<{
root: React.ReactNode
}>(rscStream)
function Shell() {
const [root, setRoot] = useState(initialPayload.root)
useEffect(() => {
const onHmrReload = () => {
const root = fetchRSC('/', {
method: 'post',
headers: { 'Content-Type': 'application/json' },
})
setRoot(root)
}
import.meta.hot?.on('rsc:update', onHmrReload)
return () => import.meta.hot?.off('rsc:update', onHmrReload)
})
return root
}
ReactDOM.hydrateRoot(document, <Shell />)
11 changes: 11 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const key = 'a'

const data = {
[key]: { hello: 'world ' },
} as Record<string, { hello: string }>

const lookup = (k: string) => {
return data[k]
}

export default lookup
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/server.ssr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as ReactClient from '@vitejs/plugin-rsc/ssr'
import React from 'react'
import * as ReactDOMServer from 'react-dom/server.edge'
import { injectRSCPayload } from 'rsc-html-stream/server'

type RscPayload = {
root: React.ReactNode
}
export async function renderHTML(rscStream: ReadableStream<Uint8Array>) {
const [rscStream1, rscStream2] = rscStream.tee()
let payload: Promise<RscPayload>
function SsrRoot() {
payload ??= ReactClient.createFromReadableStream<RscPayload>(rscStream1)
return React.use(payload).root
}
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
const htmlStream = await ReactDOMServer.renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent,
})
let responseStream: ReadableStream<Uint8Array> = htmlStream
responseStream = responseStream.pipeThrough(injectRSCPayload(rscStream2))
return responseStream
}
21 changes: 21 additions & 0 deletions packages/plugin-rsc/examples/hmr/src/server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as ReactServer from '@vitejs/plugin-rsc/rsc'
import App from './App.tsx'

export default async function handler(request: Request): Promise<Response> {
const root = <App />
const rscStream = ReactServer.renderToReadableStream({ root })
if (request.method !== 'GET') {
return new Response(rscStream, {
headers: { 'Content-type': 'text/x-component' },
})
}
const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import('./server.ssr.tsx')
>('ssr', 'index')
const htmlStream = await ssrEntryModule.renderHTML(rscStream)
return new Response(htmlStream, { headers: { 'Content-type': 'text/html' } })
}

if (import.meta.hot) {
import.meta.hot.accept()
}
17 changes: 17 additions & 0 deletions packages/plugin-rsc/examples/hmr/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client", "@vitejs/plugin-rsc/types"],
"jsx": "react-jsx"
}
}
22 changes: 22 additions & 0 deletions packages/plugin-rsc/examples/hmr/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import rsc from '@vitejs/plugin-rsc'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
clearScreen: false,
plugins: [
react(),
rsc({
entries: {
client: './src/client.tsx',
ssr: './src/server.ssr.tsx',
rsc: './src/server.tsx',
},
ignoredPackageWarnings: ['navigation-react'],
}),
],
optimizeDeps: {
include: ['navigation'],
exclude: ['navigation-react'],
},
})
26 changes: 26 additions & 0 deletions packages/plugin-rsc/examples/navigation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@vitejs/plugin-rsc-examples-navigation",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-rsc": "latest",
"navigation": "^6.3.0",
"navigation-react": "^4.13.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^7.0.4",
"vite-plugin-inspect": "^11.3.0"
}
}
Binary file not shown.
29 changes: 29 additions & 0 deletions packages/plugin-rsc/examples/navigation/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { SceneView } from 'navigation-react'
import NavigationProvider from './NavigationProvider'
import HmrProvider from './HmrProvider'
import People from './People'
import Person from './Person'

const App = async ({ url }: any) => {
return (
<html>
<head>
<title>Navigation React</title>
</head>
<body>
<NavigationProvider url={url}>
<HmrProvider>
<SceneView active="people">
<People />
</SceneView>
<SceneView active="person" refetch={['id']}>
<Person />
</SceneView>
</HmrProvider>
</NavigationProvider>
</body>
</html>
)
}

export default App;
38 changes: 38 additions & 0 deletions packages/plugin-rsc/examples/navigation/src/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client'
import { startTransition, useOptimistic } from 'react'
import { RefreshLink, useNavigationEvent } from 'navigation-react'

const Filter = () => {
const { data, stateNavigator } = useNavigationEvent()
const { name } = data
const [optimisticName, setOptimisticName] = useOptimistic(
name || '',
(_, newName) => newName,
)
return (
<div>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={optimisticName}
onChange={({ target: { value } }) => {
startTransition(() => {
setOptimisticName(value)
stateNavigator.refresh({ ...data, name: value, page: null })
})
}}
/>
</div>
Page size
<RefreshLink navigationData={{ size: 5, page: null }} includeCurrentData>
5
</RefreshLink>
<RefreshLink navigationData={{ size: 10, page: null }} includeCurrentData>
10
</RefreshLink>
</div>
)
}

export default Filter
34 changes: 34 additions & 0 deletions packages/plugin-rsc/examples/navigation/src/Friends.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { RefreshLink, useNavigationEvent } from 'navigation-react'
import { getFriends } from './data'
import Gender from './Gender'

const Friends = async () => {
const {
data: { show, id, gender },
} = useNavigationEvent()
const friends = show ? await getFriends(id, gender) : null
return (
<>
<RefreshLink
navigationData={{ show: !show }}
includeCurrentData
>{`${!show ? 'Show' : 'Hide'} Friends`}</RefreshLink>
{show && (
<>
<Gender />
<ul>
{friends?.map(({ id, name }) => (
<li key={id}>
<RefreshLink navigationData={{ id }} includeCurrentData>
{name}
</RefreshLink>
</li>
))}
</ul>
</>
)}
</>
)
}

export default Friends
35 changes: 35 additions & 0 deletions packages/plugin-rsc/examples/navigation/src/Gender.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'
import { startTransition } from 'react'
import { useNavigationEvent } from 'navigation-react'
import { useOptimistic } from 'react'

const Gender = () => {
const { data, stateNavigator } = useNavigationEvent()
const { gender } = data
const [optimisticGender, setOptimisticGender] = useOptimistic(
gender || '',
(_, newGender) => newGender,
)
return (
<div>
<label htmlFor="gender">Gender</label>
<select
id="gender"
value={optimisticGender}
onChange={({ target: { value } }) => {
startTransition(() => {
setOptimisticGender(value)
stateNavigator.refresh({ ...data, gender: value })
})
}}
>
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
)
}

export default Gender
Loading