Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ npx degit vitejs/vite-plugin-react/packages/plugin-rsc/examples/starter my-app
- This demonstrates how to integrate [experimental React Router RSC API](https://remix.run/blog/rsc-preview). React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components), so it's recommended to follow React Router's official documentation for the latest integration.
- [`./examples/basic`](./examples/basic)
- This is mainly used for e2e testing and include various advanced RSC usages (e.g. `"use cache"` example).
It also uses a high level `@vitejs/plugin-rsc/extra/{rsc,ssr,browser}` API for quick setup.
- [`./examples/ssg`](./examples/ssg)
- Static site generation (SSG) example with MDX and client components for interactivity.

Expand Down
3 changes: 0 additions & 3 deletions packages/plugin-rsc/examples/basic/src/client.tsx

This file was deleted.

127 changes: 127 additions & 0 deletions packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as ReactClient from '@vitejs/plugin-rsc/browser'
import { getRscStreamFromHtml } from '@vitejs/plugin-rsc/rsc-html-stream/browser'
import React from 'react'
import * as ReactDOMClient from 'react-dom/client'
import type { RscPayload } from './entry.rsc'

async function main() {
// stash `setPayload` function to trigger re-rendering
// from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
let setPayload: (v: RscPayload) => void

// deserialize RSC stream back to React VDOM for CSR
const initialPayload = await ReactClient.createFromReadableStream<RscPayload>(
// initial RSC stream is injected in SSR stream as <script>...FLIGHT_DATA...</script>
getRscStreamFromHtml(),
)

// browser root component to (re-)render RSC payload as state
function BrowserRoot() {
const [payload, setPayload_] = React.useState(initialPayload)

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

// re-fetch/render on client side navigation
React.useEffect(() => {
return listenNavigation(() => fetchRscPayload())
}, [])

return payload.root
}

// re-fetch RSC and trigger re-rendering
async function fetchRscPayload() {
const payload = await ReactClient.createFromFetch<RscPayload>(
fetch(window.location.href),
)
setPayload(payload)
}

// register a handler which will be internally called by React
// on server function request after hydration.
ReactClient.setServerCallback(async (id, args) => {
const url = new URL(window.location.href)
const temporaryReferences = ReactClient.createTemporaryReferenceSet()
const payload = await ReactClient.createFromFetch<RscPayload>(
fetch(url, {
method: 'POST',
body: await ReactClient.encodeReply(args, { temporaryReferences }),
headers: {
'x-rsc-action': id,
},
}),
{ temporaryReferences },
)
setPayload(payload)
return payload.returnValue
})

// hydration
const browserRoot = (
<React.StrictMode>
<BrowserRoot />
</React.StrictMode>
)
ReactDOMClient.hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
})

// implement server HMR by trigering re-fetch/render of RSC upon server code change
if (import.meta.hot) {
import.meta.hot.on('rsc:update', () => {
fetchRscPayload()
})
}
}

// a little helper to setup events interception for client side navigation
function listenNavigation(onNavigation: () => void) {
window.addEventListener('popstate', onNavigation)

const oldPushState = window.history.pushState
window.history.pushState = function (...args) {
const res = oldPushState.apply(this, args)
onNavigation()
return res
}

const oldReplaceState = window.history.replaceState
window.history.replaceState = function (...args) {
const res = oldReplaceState.apply(this, args)
onNavigation()
return res
}

function onClick(e: MouseEvent) {
let 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(null, '', link.href)
}
}
document.addEventListener('click', onClick)

return () => {
document.removeEventListener('click', onClick)
window.removeEventListener('popstate', onNavigation)
window.history.pushState = oldPushState
window.history.replaceState = oldReplaceState
}
}

main()
108 changes: 108 additions & 0 deletions packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as ReactServer from '@vitejs/plugin-rsc/rsc'
import type { ReactFormState } from 'react-dom/client'
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
}

// 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<Response> {
// 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)
returnValue = await action.apply(null, args)
} else {
// otherwise server function is called via `<form action={...}>`
// before hydration (e.g. when javascript is disabled).
// aka progressive enhancement.
const formData = await request.formData()
const decodedAction = await ReactServer.decodeAction(formData)
const result = await decodedAction()
formState = await ReactServer.decodeFormState(result, formData)
}
}

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

// respond RSC stream without HTML rendering based on framework's convention.
// here we use request header `content-type`.
// additionally we allow `?__rsc` and `?__html` to easily view payload directly.
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',
},
})
}

// Delegate to SSR environment for html rendering.
// The plugin provides `loadSsrModule` helper to allow loading SSR environment entry module
// in RSC environment. however this can be customized by implementing own runtime communication
// e.g. `@cloudflare/vite-plugin`'s service binding.
const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr.tsx')
>('ssr', 'index')
const htmlStream = await ssrEntryModule.renderHTML(rscStream, {
formState,
nonce,
// allow quick simulation of javscript disabled browser
debugNojs: url.searchParams.has('__nojs'),
})

// respond html
return new Response(htmlStream, {
headers: {
'content-type': 'text/html;charset=utf-8',
vary: 'accept',
},
})
}

if (import.meta.hot) {
import.meta.hot.accept()
}
53 changes: 53 additions & 0 deletions packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { injectRscStreamToHtml } from '@vitejs/plugin-rsc/rsc-html-stream/ssr' // helper API
import * as ReactClient from '@vitejs/plugin-rsc/ssr' // RSC API
import React from 'react'
import type { ReactFormState } from 'react-dom/client'
import * as ReactDOMServer from 'react-dom/server.edge'
import type { RscPayload } from './entry.rsc'

export async function renderHTML(
rscStream: ReadableStream<Uint8Array>,
options: {
formState?: ReactFormState
nonce?: string
debugNojs?: boolean
},
) {
// duplicate one RSC stream into two.
// - one for SSR (ReactClient.createFromReadableStream below)
// - another for browser hydration payload by injecting <script>...FLIGHT_DATA...</script>.
const [rscStream1, rscStream2] = rscStream.tee()

// deserialize RSC stream back to React VDOM
let payload: Promise<RscPayload>
function SsrRoot() {
// deserialization needs to be kicked off inside ReactDOMServer context
// for ReactDomServer preinit/preloading to work
payload ??= ReactClient.createFromReadableStream<RscPayload>(rscStream1)
return React.use(payload).root
}

// render html (traditional SSR)
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
const htmlStream = await ReactDOMServer.renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent: options?.debugNojs
? undefined
: bootstrapScriptContent,
nonce: options?.nonce,
// no types
...{ formState: options?.formState },
})

let responseStream: ReadableStream<Uint8Array> = htmlStream
if (!options?.debugNojs) {
// initial RSC stream is injected in HTML stream as <script>...FLIGHT_DATA...</script>
responseStream = responseStream.pipeThrough(
injectRscStreamToHtml(rscStream2, {
nonce: options?.nonce,
}),
)
}

return responseStream
}
3 changes: 3 additions & 0 deletions packages/plugin-rsc/examples/basic/src/framework/react.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'react-dom/server.edge' {
export * from 'react-dom/server'
}
1 change: 1 addition & 0 deletions packages/plugin-rsc/examples/basic/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function Root(props: { url: URL }) {
return (
<html>
<head>
<meta charSet="utf-8" />
<title>vite-rsc</title>
{import.meta.viteRsc.loadCss('/src/routes/root.tsx')}
</head>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { revalidateCache } from '../../use-cache-runtime'
import { revalidateCache } from '../../framework/use-cache-runtime'

export function TestUseCache() {
return (
Expand Down
1 change: 0 additions & 1 deletion packages/plugin-rsc/examples/basic/src/server.ssr.tsx

This file was deleted.

16 changes: 12 additions & 4 deletions packages/plugin-rsc/examples/basic/src/server.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { handleRequest } from './framework/entry.rsc.tsx'
import './styles.css'
import { renderRequest } from '@vitejs/plugin-rsc/extra/rsc'

export default async function handler(request: Request): Promise<Response> {
const url = new URL(request.url)
const { Root } = await import('./routes/root.tsx')
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
// https://vite.dev/guide/features.html#content-security-policy-csp
// this isn't needed if `style-src: 'unsafe-inline'` (dev) and `script-src: 'self'`
const nonceMeta = nonce && <meta property="csp-nonce" nonce={nonce} />
const root = (
<>
{import.meta.viteRsc.loadCss()}
{nonceMeta}
<Root url={url} />
</>
)
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
const response = await renderRequest(request, root, { nonce })
if (nonce) {
const response = await handleRequest({
request,
getRoot: () => root,
nonce,
})
if (nonce && response.headers.get('content-type')?.includes('text/html')) {
response.headers.set(
'content-security-policy',
`default-src 'self'; ` +
Expand Down
14 changes: 9 additions & 5 deletions packages/plugin-rsc/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export default defineConfig({
vitePluginUseCache(),
rsc({
entries: {
client: './src/client.tsx',
ssr: './src/server.ssr.tsx',
client: './src/framework/entry.browser.tsx',
ssr: './src/framework/entry.ssr.tsx',
rsc: './src/server.tsx',
},
// disable auto css injection to manually test `loadCss` feature.
Expand Down Expand Up @@ -68,11 +68,15 @@ export default defineConfig({
assert(typeof viteManifest.source === 'string')
if (this.environment.name === 'rsc') {
assert(viteManifest.source.includes('src/server.tsx'))
assert(!viteManifest.source.includes('src/client.tsx'))
assert(
!viteManifest.source.includes('src/framework/entry.browser.tsx'),
)
}
if (this.environment.name === 'client') {
assert(!viteManifest.source.includes('src/server.tsx'))
assert(viteManifest.source.includes('src/client.tsx'))
assert(
viteManifest.source.includes('src/framework/entry.browser.tsx'),
)
}
},
},
Expand Down Expand Up @@ -164,7 +168,7 @@ function vitePluginUseCache(): Plugin[] {
})
if (!result.output.hasChanged()) return
result.output.prepend(
`import __vite_rsc_cache from "/src/use-cache-runtime";`,
`import __vite_rsc_cache from "/src/framework/use-cache-runtime";`,
)
return {
code: result.output.toString(),
Expand Down
Loading