Skip to content

Commit f22dfbe

Browse files
authored
React Server Components (RSC) (#8451)
# Initial PR to add React Server Components support to Redwood RSC support is far from complete. But a simple test app is working! See screenshot below ![image](https://github.com/redwoodjs/redwood/assets/30793/b8a52420-f726-4aa9-8b5e-6956c1b4aed4) ## How to try this code * Check out this PR (`gh pr checkout 8451`) * `git clean -fdx && yarn && yarn build` * `yarn build:test-project --link ~/tmp/rw-rsc-test` * `cd ~/tmp/rw-rsc-test` * `yarn rw experimental setup-streaming-ssr`. Allow it to overwrite entry.client.tsx Now you need to add a new file, `web/src/entries.ts` ```ts export type GetEntry = (rscId: string) => Promise< | React.FunctionComponent | { default: React.FunctionComponent } | null > export function defineEntries(getEntry: GetEntry) { return { getEntry, } } export default defineEntries( // getEntry async (id) => { switch (id) { case 'App': return import('./App') default: return null } } ) ``` Update `web/src/App.tsx` to look like this ```tsx import { Counter } from './Counter' const App = ({ name = 'Anonymous' }) => { return ( <div style={{ border: '3px red dashed', margin: '1em', padding: '1em' }}> <h1>Hello {name}!!</h1> <h3>This is a server component.</h3> <Counter /> </div> ) } export default App ``` And add `web/src/Counter.tsx` ```tsx 'use client' import React from 'react' export const Counter = () => { const [count, setCount] = React.useState(0) return ( <div style={{ border: '3px blue dashed', margin: '1em', padding: '1em' }}> <p>Count: {count}</p> <button onClick={() => setCount((c) => c + 1)}>Increment</button> <h3>This is a client component.</h3> </div> ) } ``` Make these changes in `entry.client.tsx` ```diff --- a/web/src/entry.client.tsx +++ b/web/src/entry.client.tsx @@ -1,12 +1,9 @@ import { hydrateRoot, createRoot } from 'react-dom/client' +import { serve } from '@redwoodjs/vite/client' // TODO (STREAMING) This was marked "temporary workaround" // Need to figure out why it's a temporary workaround and what we // should do instead. -import { ServerContextProvider } from '@redwoodjs/web/dist/serverContext' - -import App from './App' -import { Document } from './Document' /** * When `#redwood-app` isn't empty then it's very likely that you're using @@ -16,23 +13,12 @@ import { Document } from './Document' */ const redwoodAppElement = document.getElementById('redwood-app') +const App = serve('App') + if (redwoodAppElement.children?.length > 0) { - hydrateRoot( - document, - <ServerContextProvider value={{}}> - <Document css={window.__assetMap?.()?.css}> - <App /> - </Document> - </ServerContextProvider> - ) + hydrateRoot(redwoodAppElement, <App />) } else { console.log('Rendering from scratch') - const root = createRoot(document) - root.render( - <ServerContextProvider value={{}}> - <Document css={window.__assetMap?.()?.css}> - <App /> - </Document> - </ServerContextProvider> - ) + const root = createRoot(redwoodAppElement) + root.render(<App name="Redwood RSCs" />) } ``` Add `entry.client.tsx` to `index.html` ```diff --- a/web/src/index.html +++ b/web/src/index.html @@ -5,6 +5,7 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" href="/favicon.png" /> + <script type="module" src="entry.client.tsx"></script> </head> <body> ``` Remove the redwood plugin from `vite.config.ts` ```diff --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,10 +6,10 @@ import { defineConfig, UserConfig } from 'vite' // So that Vite will load on local instead of 127.0.0.1 dns.setDefaultResultOrder('verbatim') -import redwood from '@redwoodjs/vite' +// import redwood from '@redwoodjs/vite' const viteConfig: UserConfig = { - plugins: [redwood()], + // plugins: [redwood()], } export default defineConfig(viteConfig) ``` **Build** `node ./node_modules/@redwoodjs/vite/dist/buildRscFeServer.js` **Serve** `node --conditions react-server ./node_modules/@redwoodjs/vite/dist/runRscFeServer.js`
1 parent bca98c6 commit f22dfbe

19 files changed

Lines changed: 3409 additions & 521 deletions

packages/vite/ambient.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable no-var */
2+
/// <reference types="react/canary" />
23

34
declare global {
45
var RWJS_ENV: {

packages/vite/bins/rw-vite-build.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import yargsParser from 'yargs-parser'
44

55
import { buildWeb } from '@redwoodjs/internal/dist/build/web.js'
66
import { getConfig, getPaths } from '@redwoodjs/project-config'
7-
import { buildFeServer } from '@redwoodjs/vite/dist/buildFeServer.js'
7+
import { buildFeServer } from '@redwoodjs/vite/buildFeServer'
88

99
const rwPaths = getPaths()
1010

packages/vite/modules.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare module 'react-server-dom-webpack/node-loader'
2+
declare module 'react-server-dom-webpack/server'
3+
declare module 'react-server-dom-webpack/server.node.unbundled'
4+
declare module 'react-server-dom-webpack/client'
5+
declare module 'acorn-loose'

packages/vite/package.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@
1212
"dist",
1313
"inject"
1414
],
15-
"main": "dist/index.js",
15+
"exports": {
16+
"./package.json": "./package.json",
17+
".": {
18+
"types": "./dist/index.d.ts",
19+
"default": "./dist/index.js"
20+
},
21+
"./client": {
22+
"types": "./dist/client.d.ts",
23+
"default": "./dist/client.js"
24+
},
25+
"./buildFeServer": {
26+
"types": "./dist/buildFeServer.d.ts",
27+
"default": "./dist/buildFeServer.js"
28+
}
29+
},
1630
"bin": {
1731
"rw-dev-fe": "./dist/devFeServer.js",
1832
"rw-serve-fe": "./dist/runFeServer.js",
@@ -33,20 +47,25 @@
3347
"@redwoodjs/internal": "5.0.0",
3448
"@redwoodjs/project-config": "5.0.0",
3549
"@redwoodjs/web": "5.0.0",
50+
"@swc/core": "1.3.60",
3651
"@vitejs/plugin-react": "4.0.1",
52+
"acorn-loose": "^8.3.0",
3753
"buffer": "6.0.3",
3854
"core-js": "3.31.0",
3955
"dotenv-defaults": "5.0.2",
4056
"express": "4.18.2",
4157
"http-proxy-middleware": "2.0.6",
4258
"isbot": "3.6.8",
59+
"react": "18.3.0-canary-035a41c4e-20230704",
60+
"react-server-dom-webpack": "18.3.0-canary-035a41c4e-20230704",
4361
"vite": "4.3.9",
4462
"vite-plugin-environment": "1.1.3",
4563
"yargs-parser": "21.1.1"
4664
},
4765
"devDependencies": {
4866
"@babel/cli": "7.22.5",
4967
"@types/express": "4",
68+
"@types/react": "18.2.14",
5069
"@types/yargs-parser": "21.0.0",
5170
"glob": "10.3.1",
5271
"jest": "29.5.0",
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import fs from 'fs/promises'
2+
import path from 'path'
3+
4+
import react from '@vitejs/plugin-react'
5+
import { build as viteBuild } from 'vite'
6+
import type { Manifest as ViteBuildManifest } from 'vite'
7+
8+
import { RouteSpec } from '@redwoodjs/internal/dist/routes'
9+
import { getAppRouteHook, getPaths } from '@redwoodjs/project-config'
10+
11+
import { RWRouteManifest } from './types'
12+
import { serverBuild } from './waku-lib/build-server'
13+
import { rscAnalyzePlugin, rscIndexPlugin } from './waku-lib/vite-plugin-rsc'
14+
15+
interface BuildOptions {
16+
verbose?: boolean
17+
}
18+
19+
export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => {
20+
const rwPaths = getPaths()
21+
22+
const clientEntryFileSet = new Set<string>()
23+
const serverEntryFileSet = new Set<string>()
24+
25+
/**
26+
* RSC build
27+
* Uses rscAnalyzePlugin to collect client and server entry points
28+
* Starts building the AST in entries.ts
29+
* Doesn't output any files, only collects a list of RSCs and RSFs
30+
*/
31+
await viteBuild({
32+
// ...configFileConfig,
33+
root: rwPaths.base,
34+
plugins: [
35+
react(),
36+
{
37+
name: 'rsc-test-plugin',
38+
transform(_code, id) {
39+
console.log('rsc-test-plugin id', id)
40+
},
41+
},
42+
rscAnalyzePlugin(
43+
(id) => clientEntryFileSet.add(id),
44+
(id) => serverEntryFileSet.add(id)
45+
),
46+
],
47+
// ssr: {
48+
// // FIXME Without this, waku/router isn't considered to have client
49+
// // entries, and "No client entry" error occurs.
50+
// // Unless we fix this, RSC-capable packages aren't supported.
51+
// // This also seems to cause problems with pnpm.
52+
// // noExternal: ['@redwoodjs/web', '@redwoodjs/router'],
53+
// },
54+
build: {
55+
write: false,
56+
ssr: true,
57+
rollupOptions: {
58+
input: {
59+
// entries: rwPaths.web.entryServer,
60+
entries: path.join(rwPaths.web.src, 'entries.ts'),
61+
},
62+
},
63+
},
64+
})
65+
66+
const clientEntryFiles = Object.fromEntries(
67+
Array.from(clientEntryFileSet).map((filename, i) => [`rsc${i}`, filename])
68+
)
69+
const serverEntryFiles = Object.fromEntries(
70+
Array.from(serverEntryFileSet).map((filename, i) => [`rsf${i}`, filename])
71+
)
72+
73+
console.log('clientEntryFileSet', Array.from(clientEntryFileSet))
74+
console.log('serverEntryFileSet', Array.from(serverEntryFileSet))
75+
console.log('clientEntryFiles', clientEntryFiles)
76+
console.log('serverEntryFiles', serverEntryFiles)
77+
78+
const clientEntryPath = rwPaths.web.entryClient
79+
80+
if (!clientEntryPath) {
81+
throw new Error(
82+
'Vite client entry point not found. Please check that your project ' +
83+
'has an entry.client.{jsx,tsx} file in the web/src directory.'
84+
)
85+
}
86+
87+
const clientBuildOutput = await viteBuild({
88+
// ...configFileConfig,
89+
root: rwPaths.web.src,
90+
plugins: [
91+
// TODO (RSC) Update index.html to include the entry.client.js script
92+
// TODO (RSC) Do the above in the exp-rsc setup command
93+
// {
94+
// name: 'redwood-plugin-vite',
95+
96+
// // ---------- Bundle injection ----------
97+
// // Used by rollup during build to inject the entrypoint
98+
// // but note index.html does not come through as an id during dev
99+
// transform: (code: string, id: string) => {
100+
// if (
101+
// existsSync(clientEntryPath) &&
102+
// // TODO (RSC) Is this even needed? We throw if we can't find it above
103+
// // TODO (RSC) Consider making this async (if we do need it)
104+
// normalizePath(id) === normalizePath(rwPaths.web.html)
105+
// ) {
106+
// const newCode = code.replace(
107+
// '</head>',
108+
// '<script type="module" src="entry.client.jsx"></script></head>'
109+
// )
110+
//
111+
// return { code: newCode, map: null }
112+
// } else {
113+
// // Returning null as the map preserves the original sourcemap
114+
// return { code, map: null }
115+
// }
116+
// },
117+
// },
118+
react(),
119+
rscIndexPlugin(),
120+
],
121+
build: {
122+
outDir: rwPaths.web.dist,
123+
emptyOutDir: true, // Needed because `outDir` is not inside `root`
124+
// TODO (RSC) Enable this when we switch to a server-first approach
125+
// emptyOutDir: false, // Already done when building server
126+
rollupOptions: {
127+
input: {
128+
main: rwPaths.web.html,
129+
...clientEntryFiles,
130+
},
131+
preserveEntrySignatures: 'exports-only',
132+
},
133+
manifest: 'build-manifest.json',
134+
},
135+
esbuild: {
136+
logLevel: 'debug',
137+
},
138+
})
139+
140+
if (!('output' in clientBuildOutput)) {
141+
throw new Error('Unexpected vite client build output')
142+
}
143+
144+
const serverBuildOutput = await serverBuild(
145+
// rwPaths.web.entryServer,
146+
path.join(rwPaths.web.src, 'entries.ts'),
147+
clientEntryFiles,
148+
serverEntryFiles,
149+
{}
150+
)
151+
152+
const clientEntries: Record<string, string> = {}
153+
for (const item of clientBuildOutput.output) {
154+
const { name, fileName } = item
155+
const entryFile =
156+
name &&
157+
serverBuildOutput.output.find(
158+
(item) =>
159+
'moduleIds' in item &&
160+
item.moduleIds.includes(clientEntryFiles[name] as string)
161+
)?.fileName
162+
if (entryFile) {
163+
clientEntries[entryFile] = fileName
164+
}
165+
}
166+
167+
console.log('clientEntries', clientEntries)
168+
169+
await fs.appendFile(
170+
path.join(rwPaths.web.distServer, 'entries.js'),
171+
`export const clientEntries=${JSON.stringify(clientEntries)};`
172+
)
173+
174+
// // Step 1A: Generate the client bundle
175+
// await buildWeb({ verbose })
176+
177+
// const rollupInput = {
178+
// entries: rwPaths.web.entryServer,
179+
// ...clientEntryFiles,
180+
// ...serverEntryFiles,
181+
// }
182+
183+
// Step 1B: Generate the server output
184+
// await build({
185+
// // TODO (RSC) I had this marked as 'FIXME'. I guess I just need to make
186+
// // sure we still include it, or at least make it possible for users to pass
187+
// // in their own config
188+
// // configFile: viteConfig,
189+
// ssr: {
190+
// noExternal: Array.from(clientEntryFileSet).map(
191+
// // TODO (RSC) I think the comment below is from waku. We don't care
192+
// // about pnpm, do we? Does it also affect yarn?
193+
// // FIXME this might not work with pnpm
194+
// // TODO (RSC) No idea what's going on here
195+
// (filename) => {
196+
// const nodeModulesPath = path.join(rwPaths.base, 'node_modules')
197+
// console.log('nodeModulesPath', nodeModulesPath)
198+
// const relativePath = path.relative(nodeModulesPath, filename)
199+
// console.log('relativePath', relativePath)
200+
// console.log('first split', relativePath.split('/')[0])
201+
202+
// return relativePath.split('/')[0]
203+
// }
204+
// ),
205+
// },
206+
// build: {
207+
// // Because we configure the root to be web/src, we need to go up one level
208+
// outDir: rwPaths.web.distServer,
209+
// // TODO (RSC) Maybe we should re-enable this. I can't remember anymore)
210+
// // What does 'ssr' even mean?
211+
// // ssr: rwPaths.web.entryServer,
212+
// rollupOptions: {
213+
// input: {
214+
// // TODO (RSC) entries: rwPaths.web.entryServer,
215+
// ...clientEntryFiles,
216+
// ...serverEntryFiles,
217+
// },
218+
// output: {
219+
// banner: (chunk) => {
220+
// console.log('chunk', chunk)
221+
222+
// // HACK to bring directives to the front
223+
// let code = ''
224+
225+
// if (chunk.moduleIds.some((id) => clientEntryFileSet.has(id))) {
226+
// code += '"use client";'
227+
// }
228+
229+
// if (chunk.moduleIds.some((id) => serverEntryFileSet.has(id))) {
230+
// code += '"use server";'
231+
// }
232+
233+
// console.log('code', code)
234+
// return code
235+
// },
236+
// entryFileNames: (chunkInfo) => {
237+
// console.log('chunkInfo', chunkInfo)
238+
239+
// // TODO (RSC) Don't hardcode 'entry.server'
240+
// if (chunkInfo.name === 'entry.server') {
241+
// return '[name].js'
242+
// }
243+
244+
// return 'assets/[name].js'
245+
// },
246+
// },
247+
// },
248+
// },
249+
// envFile: false,
250+
// logLevel: verbose ? 'info' : 'warn',
251+
// })
252+
253+
// Step 3: Generate route-manifest.json
254+
255+
// TODO When https://github.com/tc39/proposal-import-attributes and
256+
// https://github.com/microsoft/TypeScript/issues/53656 have both landed we
257+
// should try to do this instead:
258+
// const clientBuildManifest: ViteBuildManifest = await import(
259+
// path.join(getPaths().web.dist, 'build-manifest.json'),
260+
// { with: { type: 'json' } }
261+
// )
262+
// NOTES:
263+
// * There's a related babel plugin here
264+
// https://babeljs.io/docs/babel-plugin-syntax-import-attributes
265+
// * Included in `preset-env` if you set `shippedProposals: true`
266+
// * We had this before, but with `assert` instead of `with`. We really
267+
// should be using `with`. See motivation in issues linked above.
268+
// * With `assert` and `@babel/plugin-syntax-import-assertions` the
269+
// code compiled and ran properly, but Jest tests failed, complaining
270+
// about the syntax.
271+
const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json')
272+
const buildManifestStr = await fs.readFile(manifestPath, 'utf-8')
273+
const clientBuildManifest: ViteBuildManifest = JSON.parse(buildManifestStr)
274+
275+
// TODO (RSC) We don't have support for a router yet, so skip all routes
276+
const routesList = [] as RouteSpec[] // getProjectRoutes()
277+
278+
// This is all a no-op for now
279+
const routeManifest = routesList.reduce<RWRouteManifest>((acc, route) => {
280+
acc[route.path] = {
281+
name: route.name,
282+
bundle: route.relativeFilePath
283+
? clientBuildManifest[route.relativeFilePath].file
284+
: null,
285+
matchRegexString: route.matchRegexString,
286+
// NOTE this is the path definition, not the actual path
287+
// E.g. /blog/post/{id:Int}
288+
pathDefinition: route.path,
289+
hasParams: route.hasParams,
290+
routeHooks: FIXME_constructRouteHookPath(route.routeHooks),
291+
redirect: route.redirect
292+
? {
293+
to: route.redirect?.to,
294+
permanent: false,
295+
}
296+
: null,
297+
renderMode: route.renderMode,
298+
}
299+
300+
return acc
301+
}, {})
302+
303+
await fs.writeFile(rwPaths.web.routeManifest, JSON.stringify(routeManifest))
304+
}
305+
306+
// TODO (STREAMING) Hacky work around because when you don't have a App.routeHook, esbuild doesn't create
307+
// the pages folder in the dist/server/routeHooks directory.
308+
// @MARK need to change to .mjs here if we use esm
309+
const FIXME_constructRouteHookPath = (rhSrcPath: string | null | undefined) => {
310+
const rwPaths = getPaths()
311+
if (!rhSrcPath) {
312+
return null
313+
}
314+
315+
if (getAppRouteHook()) {
316+
return path.relative(rwPaths.web.src, rhSrcPath).replace('.ts', '.js')
317+
} else {
318+
return path
319+
.relative(path.join(rwPaths.web.src, 'pages'), rhSrcPath)
320+
.replace('.ts', '.js')
321+
}
322+
}
323+
324+
if (require.main === module) {
325+
const verbose = process.argv.includes('--verbose')
326+
buildFeServer({ verbose })
327+
}

0 commit comments

Comments
 (0)