refactor: remove unused components and files; update List component to use client-side rendering

- Deleted List.tsx, action.tsx, react.svg, client.tsx, entry.browser.tsx, entry.rsc.tsx, entry.ssr.tsx, error-boundary.tsx, request.tsx, index.css, and root.tsx as they were no longer needed.
- Updated List component in pages/a/index.tsx to use useEffect for client-side behavior.
- Removed pages/b/index.tsx as it was redundant.
- Added new browser-entry.tsx and entry.tsx for client-side and server-side rendering respectively.
- Introduced versioned component in pages/v/a/index.tsx to demonstrate async data fetching.
- Updated tsconfig.json to allow unused local variables.x
This commit is contained in:
2026-04-14 11:10:58 +08:00
parent f22899e424
commit 4cf060136d
23 changed files with 912 additions and 1511 deletions

View File

@@ -1,12 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta charset="UTF-8" />
<title>RSC App</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head> <title>RSC App</title>
<body> </head>
<div id="root"></div>
<script type="module" src="/src/entry-client.tsx"></script> <body>
</body> <div id="root">
</div>
<script type="module" src="/src/browser-entry.tsx"></script>
</body>
</html> </html>

View File

@@ -5,20 +5,18 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "bun run --host src/entry.tsx"
"build": "vite build",
"preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/cssinjs": "^2.1.2",
"antd": "^6.3.5",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest", "rsc-html-stream": "^0.0.7"
"@vitejs/plugin-rsc": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^8.0.8"
} }
} }

1326
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

125
server.js
View File

@@ -1,125 +0,0 @@
// Production server for RSC
// Run with: node server.js
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join, extname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const PORT = process.env.PORT || 3000
// Import RSC handler - it exports { fetch: handler }
const rscHandler = (await import('./dist/rsc/index.js')).default.fetch
// MIME types
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
// Serve static client assets first (these don't go through RSC handler)
if (url.pathname.startsWith('/dist/client') ||
url.pathname.startsWith('/assets') ||
url.pathname === '/vite.svg') {
// Map paths to dist/client/*
let distPath
if (url.pathname.startsWith('/assets')) {
distPath = join(__dirname, '/dist/client', url.pathname)
} else if (url.pathname === '/vite.svg') {
distPath = join(__dirname, '/dist/client/vite.svg')
} else {
distPath = join(__dirname, url.pathname)
}
try {
const content = await readFile(distPath)
const ext = extname(distPath)
const contentType = mimeTypes[ext] || 'application/octet-stream'
res.setHeader('Content-Type', contentType)
res.end(content)
} catch (err) {
res.statusCode = 404
res.end('Not Found')
}
return
}
// All other routes use RSC handler (handles SSR, RSC, and dynamic pages)
try {
// Read the body first (for POST requests)
const bodyContent = req.method !== 'GET'
? await new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
: undefined
// Clone the headers (req.headers is an object, not Map)
const headers = new Headers()
for (const key of Object.keys(req.headers)) {
const value = req.headers[key]
if (key.toLowerCase() !== 'content-length') {
headers.set(key, value)
}
}
// Create request with absolute URL (RSC handler requires full URL)
const rscRequest = new Request(`http://localhost:${PORT}${url.pathname}${url.search}`, {
method: req.method,
headers,
body: bodyContent,
})
const response = await rscHandler(rscRequest)
res.statusCode = response.status
// Forward headers
for (const [key, value] of response.headers.entries()) {
if (key !== 'transfer-encoding' && key !== 'content-length') {
res.setHeader(key, value)
}
}
// For RSC stream, pipe directly
if (response.headers.get('content-type')?.includes('x-component')) {
response.body.pipe(res)
return
}
// For HTML, inject correct client assets base path
const html = await response.text()
const modifiedHtml = html.replace(
/import\("\/@id\/__x00__/g,
'import("/dist/client/@id/__x00__'
).replace(
/href="\/src\//g,
'href="/dist/client/src/'
).replace(
/src="\/src\//g,
'src="/dist/client/src/'
)
res.setHeader('Content-Type', 'text/html')
res.end(modifiedHtml)
} catch (err) {
console.error('RSC handler error:', err)
res.statusCode = 500
res.end('Internal Server Error')
}
})
server.listen(PORT, () => {
console.log(`RSC Server running at http://localhost:${PORT}/`)
console.log(`Press Ctrl+C to stop`)
})

View File

@@ -1,9 +0,0 @@
'use server';
export default async function List() {
return (
<div>
<h1>List</h1>
</div>
);
}

View File

@@ -1,11 +0,0 @@
'use server'
let serverCounter = 0
export async function getServerCounter() {
return serverCounter
}
export async function updateServerCounter(change: number) {
serverCounter += change
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

14
src/browser-entry.tsx Normal file
View File

@@ -0,0 +1,14 @@
// entry.tsx - 客户端入口
import { hydrateRoot, createRoot } from 'react-dom/client';
import A from './pages/a/index';
const root = document.getElementById('root');
createRoot(root!).render(<A />);
// hydrateRoot(document.getElementById('root')!, <A />);
setTimeout(() => {
console.log('Hydrating with new content...');
hydrateRoot(root!, <A />);
}, 30000)

View File

@@ -1,13 +0,0 @@
'use client'
import React from 'react'
export function ClientCounter() {
const [count, setCount] = React.useState(0)
return (
<button onClick={() => setCount((count) => count + 1)}>
Client Counter: {count}
</button>
)
}

15
src/entry.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { renderToString } from 'react-dom/server';
import A from './pages/a/index';
import http from 'http';
const html = renderToString(<A />, {
identifierPrefix: 'test-',
});
console.log(html);
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}).listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});

View File

@@ -1,138 +0,0 @@
import {
createFromReadableStream,
createFromFetch,
setServerCallback,
createTemporaryReferenceSet,
encodeReply,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { GlobalErrorBoundary } from './error-boundary'
import { createRscRenderRequest } from './request'
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 createFromReadableStream<RscPayload>(
// initial RSC stream is injected in SSR stream as <script>...FLIGHT_DATA...</script>
rscStream,
)
// 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 renderRequest = createRscRenderRequest(window.location.href)
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
setPayload(payload)
}
// register a handler which will be internally called by React
// on server function request after hydration.
setServerCallback(async (id, args) => {
const temporaryReferences = createTemporaryReferenceSet()
const renderRequest = createRscRenderRequest(window.location.href, {
id,
body: await encodeReply(args, { temporaryReferences }),
})
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
temporaryReferences,
})
setPayload(payload)
const { ok, data } = payload.returnValue!
if (!ok) throw data
return data
})
// hydration
const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
)
if ('__NO_HYDRATE' in globalThis) {
createRoot(document).render(browserRoot)
} else {
hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
})
}
// implement server HMR by triggering 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()

View File

@@ -1,173 +0,0 @@
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 { parseRenderRequest } from './request.tsx'
import type { ComponentType } 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?: { ok: boolean; data: unknown }
// server action form state (e.g. useActionState) of progressive enhancement case
formState?: ReactFormState
}
// Dynamic page module loading - all pages are lazy loaded on demand
const pageModules = import.meta.glob('../pages/**/*.tsx', { eager: false })
// Find page component by pathname - dynamically resolves paths
async function findPage(pathname: string): Promise<ComponentType<any> | null> {
const segments = pathname.split('/').filter(Boolean)
// Build candidate paths to try
const candidates: string[] = []
if (segments.length === 0) {
// Root path: try index.tsx
candidates.push('../pages/index.tsx')
} else {
// Try original case path: /a -> pages/a/index.tsx, /blog -> pages/blog/index.tsx
const originalPath = `../pages/${segments.join('/')}/index.tsx`
candidates.push(originalPath)
// Try capitalized file: /pageA -> pages/PageA.tsx
const capitalizedPath = `../pages/${segments.map(s =>
s.charAt(0).toUpperCase() + s.slice(1)
).join('/')}.tsx`
candidates.push(capitalizedPath)
// Try capitalized index route: /a -> pages/A/index.tsx
const capitalizedIndexPath = `${capitalizedPath.replace('.tsx', '')}/index.tsx`
candidates.push(capitalizedIndexPath)
}
// Find first matching module
for (const candidate of candidates) {
const loader = pageModules[candidate]
if (loader) {
const mod = await loader() as { default: ComponentType<any> }
return mod.default
}
}
return null
}
// 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.
export default { fetch: handler }
async function handler(request: Request): Promise<Response> {
// differentiate RSC, SSR, action, etc.
const renderRequest = parseRenderRequest(request)
request = renderRequest.request
// handle server function request
let returnValue: RscPayload['returnValue'] | undefined
let formState: ReactFormState | undefined
let temporaryReferences: unknown | undefined
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(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 `<form action={...}>`
// before hydration (e.g. when javascript is disabled).
// aka progressive enhancement.
const formData = await request.formData()
const decodedAction = await decodeAction(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,
})
}
}
}
// Dynamic page resolution based on URL pathname
const pathname = renderRequest.url.pathname
const Page = await findPage(pathname)
// If no page found and it's not the root path, return 404
if (!Page && pathname !== '/') {
return new Response('Not Found: ' + pathname, { status: 404 })
}
// serialization from React VDOM tree to RSC stream.
// we render RSC stream after handling server function request
// so that new render reflects updated state from server function call
// to achieve single round trip to mutate and fetch from server.
const rscPayload: RscPayload = {
root: <Root url={renderRequest.url} page={Page ? <Page /> : undefined} />,
formState,
returnValue,
}
const rscOptions = { temporaryReferences }
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions)
// 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',
},
})
}
// Delegate to SSR environment for html rendering.
// The plugin provides `loadModule` 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 ssrResult = await ssrEntryModule.renderHTML(rscStream, {
formState,
// allow quick simulation of javascript disabled browser
debugNojs: renderRequest.url.searchParams.has('__nojs'),
})
// respond html
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
'Content-type': 'text/html',
},
})
}
if (import.meta.hot) {
import.meta.hot.accept()
}

View File

@@ -1,74 +0,0 @@
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
import React from 'react'
import type { ReactFormState } from 'react-dom/client'
import { renderToReadableStream } from 'react-dom/server.edge'
import { injectRSCPayload } from 'rsc-html-stream/server'
import type { RscPayload } from './entry.rsc'
export async function renderHTML(
rscStream: ReadableStream<Uint8Array>,
options: {
formState?: ReactFormState
nonce?: string
debugNojs?: boolean
},
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
// 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> | undefined
function SsrRoot() {
// deserialization needs to be kicked off inside ReactDOMServer context
// for ReactDomServer preinit/preloading to work
payload ??= createFromReadableStream<RscPayload>(rscStream1)
return React.use(payload).root
}
// render html (traditional SSR)
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
let htmlStream: ReadableStream<Uint8Array>
let status: number | undefined
try {
htmlStream = await renderToReadableStream(<SsrRoot />, {
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(
<html>
<body>
<noscript>Internal Server Error: SSR failed</noscript>
</body>
</html>,
{
bootstrapScriptContent:
`self.__NO_HYDRATE=1;` +
(options?.debugNojs ? '' : bootstrapScriptContent),
nonce: options?.nonce,
},
)
}
let responseStream: ReadableStream<Uint8Array> = htmlStream
if (!options?.debugNojs) {
// initial RSC stream is injected in HTML stream as <script>...FLIGHT_DATA...</script>
// using utility made by devongovett https://github.com/devongovett/rsc-html-stream
responseStream = responseStream.pipeThrough(
injectRSCPayload(rscStream2, {
nonce: options?.nonce,
}),
)
}
return { stream: responseStream, status }
}

View File

@@ -1,81 +0,0 @@
'use client'
import React from 'react'
// Minimal ErrorBoundary example to handle errors globally on browser
export function GlobalErrorBoundary(props: { children?: React.ReactNode }) {
return (
<ErrorBoundary errorComponent={DefaultGlobalErrorPage}>
{props.children}
</ErrorBoundary>
)
}
// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx
// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
class ErrorBoundary extends React.Component<{
children?: React.ReactNode
errorComponent: React.FC<{
error: Error
reset: () => void
}>
}> {
state: { error?: Error } = {}
static getDerivedStateFromError(error: Error) {
return { error }
}
reset = () => {
this.setState({ error: null })
}
render() {
const error = this.state.error
if (error) {
return <this.props.errorComponent error={error} reset={this.reset} />
}
return this.props.children
}
}
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145
function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
return (
<html>
<head>
<title>Unexpected Error</title>
</head>
<body
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
placeContent: 'center',
placeItems: 'center',
fontSize: '16px',
fontWeight: 400,
lineHeight: '24px',
}}
>
<p>Caught an unexpected error</p>
<pre>
Error:{' '}
{import.meta.env.DEV && 'message' in props.error
? props.error.message
: '(Unknown)'}
</pre>
<button
onClick={() => {
React.startTransition(() => {
props.reset()
})
}}
>
Reset
</button>
</body>
</html>
)
}

View File

@@ -1,58 +0,0 @@
// Framework conventions (arbitrary choices for this demo):
// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests
// - Use `x-rsc-action` header to pass server action ID
const URL_POSTFIX = '_.rsc'
const HEADER_ACTION_ID = 'x-rsc-action'
// Parsed request information used to route between RSC/SSR rendering and action handling.
// Created by parseRenderRequest() from incoming HTTP requests.
type RenderRequest = {
isRsc: boolean // true if request should return RSC payload (via _.rsc suffix)
isAction: boolean // true if this is a server action call (POST request)
actionId?: string // server action ID from x-rsc-action header
request: Request // normalized Request with _.rsc suffix removed from URL
url: URL // normalized URL with _.rsc suffix removed
}
export function createRscRenderRequest(
urlString: string,
action?: { id: string; body: BodyInit },
): Request {
const url = new URL(urlString)
url.pathname += URL_POSTFIX
const headers = new Headers()
if (action) {
headers.set(HEADER_ACTION_ID, action.id)
}
return new Request(url.toString(), {
method: action ? 'POST' : 'GET',
headers,
body: action?.body,
})
}
export function parseRenderRequest(request: Request): RenderRequest {
const url = new URL(request.url)
const isAction = request.method === 'POST'
if (url.pathname.endsWith(URL_POSTFIX)) {
url.pathname = url.pathname.slice(0, -URL_POSTFIX.length)
const actionId = request.headers.get(HEADER_ACTION_ID) || undefined
if (request.method === 'POST' && !actionId) {
throw new Error('Missing action id header for RSC action request')
}
return {
isRsc: true,
isAction,
actionId,
request: new Request(url, request),
url,
}
} else {
return {
isRsc: false,
isAction,
request,
url,
}
}
}

View File

@@ -1,112 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 1rem;
}
.read-the-docs {
color: #888;
text-align: left;
}

View File

@@ -1,9 +1,12 @@
'use server'; import { useEffect } from "react";
export default async function List() { export default function List() {
useEffect(() => {
console.log('useEffect in List');
}, []);
return ( return (
<div> <div>
<h1>List A</h1> <h1>List 2</h1>
</div> </div>
); );
} }

View File

@@ -1,10 +0,0 @@
'use server';
export default async function List() {
return (
<div>
<h1>List B</h1>
<div>123</div>
</div>
);
}

18
src/pages/v/a/index.tsx Normal file
View File

@@ -0,0 +1,18 @@
'use server';
import { useEffect } from "react";
const getVersion = async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
return '1.0.0';
}
export default async function List() {
const v = await getVersion();
return (
<div>
<h1>List - Version {v}</h1>
<div style={{
width: 200
}}>Primary Button</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import './index.css' // css import is automatically injected in exported server components
import viteLogo from '/vite.svg'
import { getServerCounter, updateServerCounter } from './action.tsx'
import reactLogo from './assets/react.svg'
import { ClientCounter } from './client.tsx'
import List from './List'
export function Root(props: { url: URL; page?: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + RSC</title>
</head>
<body>
<App {...props} />
</body>
</html>
)
}
function App(props: { url: URL; page?: React.ReactNode }) {
return (
<div id="root">
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a
href="https://react.dev/reference/rsc/server-components"
target="_blank"
>
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + RSC</h1>
{/* Dynamic page content */}
{props.page || <DefaultHome />}
<ul className="read-the-docs">
<li>
Edit <code>src/client.tsx</code> to test client HMR.
</li>
<li>
Edit <code>src/root.tsx</code> to test server HMR.
</li>
<li>
Visit{' '}
<a href="./_.rsc" target="_blank">
<code>_.rsc</code>
</a>{' '}
to view RSC stream payload.
</li>
<li>
Visit{' '}
<a href="?__nojs" target="_blank">
<code>?__nojs</code>
</a>{' '}
to test server action without js enabled.
</li>
</ul>
</div>
)
}
// Default home page content when no dynamic page is provided
function DefaultHome() {
return (
<>
<div className="card">
<ClientCounter />
</div>
<div className="card">
<form action={updateServerCounter.bind(null, 1)}>
<button>Server Counter: {getServerCounter()}</button>
</form>
</div>
<div className="card">Request URL: Home</div>
<div className="card">
<List />
</div>
</>
)
}

29
src/ssr.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import { renderToReadableStream } from 'react-dom/server';
import VA from './pages/v/a';
import { createRoot, hydrateRoot } from 'react-dom/client'
export async function renderToString(comp: React.ReactElement): Promise<string> {
const response = await renderToReadableStream(
<>{comp}</>,
{
bootstrapScripts: []
}
);
const reader = response.getReader();
const decoder = new TextDecoder();
let html = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
html += decoder.decode(value);
}
return html;
}
console.log(await renderToString(<VA />));
// const root = createRoot(document.getElementById('root')!);
// root.render(<VA />);
// hydrateRoot(document.getElementById('root')!, <VA />);
//
// console.log(await renderToString(<VA />));

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": true,
"skipLibCheck": true, "skipLibCheck": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
@@ -10,8 +10,13 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"module": "ESNext", "module": "ESNext",
"target": "ESNext", "target": "ESNext",
"lib": ["ESNext", "DOM"], "lib": [
"types": ["vite/client", "@vitejs/plugin-rsc/types"], "ESNext",
"DOM"
],
"types": [
"node",
],
"jsx": "react-jsx" "jsx": "react-jsx"
} }
} }

View File

@@ -1,72 +0,0 @@
import react from '@vitejs/plugin-react'
import rsc from '@vitejs/plugin-rsc'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
rsc({
// `entries` option is only a shorthand for specifying each `rollupOptions.input` below
// > entries: { rsc, ssr, client },
//
// by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`.
// This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`.
// > serverHandler: false
}),
// use any of react plugins https://github.com/vitejs/vite-plugin-react
// to enable client component HMR
react(),
// use https://github.com/antfu-collective/vite-plugin-inspect
// to understand internal transforms required for RSC.
// import("vite-plugin-inspect").then(m => m.default()),
],
// specify entry point for each environment.
// (currently the plugin assumes `rollupOptions.input.index` for some features.)
environments: {
// `rsc` environment loads modules with `react-server` condition.
// this environment is responsible for:
// - RSC stream serialization (React VDOM -> RSC stream)
// - server functions handling
rsc: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.rsc.tsx',
},
},
},
},
// `ssr` environment loads modules without `react-server` condition.
// this environment is responsible for:
// - RSC stream deserialization (RSC stream -> React VDOM)
// - traditional SSR (React VDOM -> HTML string/stream)
ssr: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.ssr.tsx',
},
},
},
},
// client environment is used for hydration and client-side rendering
// this environment is responsible for:
// - RSC stream deserialization (RSC stream -> React VDOM)
// - traditional CSR (React VDOM -> Browser DOM tree mount/hydration)
// - refetch and re-render RSC
// - calling server functions
client: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.browser.tsx',
},
},
},
},
},
})