Skip to content

Commit

Permalink
React Server Components (RSC) (#8451)
Browse files Browse the repository at this point in the history
# 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`
  • Loading branch information
Tobbe committed Jul 6, 2023
1 parent bca98c6 commit f22dfbe
Show file tree
Hide file tree
Showing 19 changed files with 3,409 additions and 521 deletions.
1 change: 1 addition & 0 deletions packages/vite/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-var */
/// <reference types="react/canary" />

declare global {
var RWJS_ENV: {
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/bins/rw-vite-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import yargsParser from 'yargs-parser'

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

const rwPaths = getPaths()

Expand Down
5 changes: 5 additions & 0 deletions packages/vite/modules.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module 'react-server-dom-webpack/node-loader'
declare module 'react-server-dom-webpack/server'
declare module 'react-server-dom-webpack/server.node.unbundled'
declare module 'react-server-dom-webpack/client'
declare module 'acorn-loose'
21 changes: 20 additions & 1 deletion packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,21 @@
"dist",
"inject"
],
"main": "dist/index.js",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"default": "./dist/client.js"
},
"./buildFeServer": {
"types": "./dist/buildFeServer.d.ts",
"default": "./dist/buildFeServer.js"
}
},
"bin": {
"rw-dev-fe": "./dist/devFeServer.js",
"rw-serve-fe": "./dist/runFeServer.js",
Expand All @@ -33,20 +47,25 @@
"@redwoodjs/internal": "5.0.0",
"@redwoodjs/project-config": "5.0.0",
"@redwoodjs/web": "5.0.0",
"@swc/core": "1.3.60",
"@vitejs/plugin-react": "4.0.1",
"acorn-loose": "^8.3.0",
"buffer": "6.0.3",
"core-js": "3.31.0",
"dotenv-defaults": "5.0.2",
"express": "4.18.2",
"http-proxy-middleware": "2.0.6",
"isbot": "3.6.8",
"react": "18.3.0-canary-035a41c4e-20230704",
"react-server-dom-webpack": "18.3.0-canary-035a41c4e-20230704",
"vite": "4.3.9",
"vite-plugin-environment": "1.1.3",
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@babel/cli": "7.22.5",
"@types/express": "4",
"@types/react": "18.2.14",
"@types/yargs-parser": "21.0.0",
"glob": "10.3.1",
"jest": "29.5.0",
Expand Down
327 changes: 327 additions & 0 deletions packages/vite/src/buildRscFeServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
import fs from 'fs/promises'
import path from 'path'

import react from '@vitejs/plugin-react'
import { build as viteBuild } from 'vite'
import type { Manifest as ViteBuildManifest } from 'vite'

import { RouteSpec } from '@redwoodjs/internal/dist/routes'
import { getAppRouteHook, getPaths } from '@redwoodjs/project-config'

import { RWRouteManifest } from './types'
import { serverBuild } from './waku-lib/build-server'
import { rscAnalyzePlugin, rscIndexPlugin } from './waku-lib/vite-plugin-rsc'

interface BuildOptions {
verbose?: boolean
}

export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => {
const rwPaths = getPaths()

const clientEntryFileSet = new Set<string>()
const serverEntryFileSet = new Set<string>()

/**
* RSC build
* Uses rscAnalyzePlugin to collect client and server entry points
* Starts building the AST in entries.ts
* Doesn't output any files, only collects a list of RSCs and RSFs
*/
await viteBuild({
// ...configFileConfig,
root: rwPaths.base,
plugins: [
react(),
{
name: 'rsc-test-plugin',
transform(_code, id) {
console.log('rsc-test-plugin id', id)
},
},
rscAnalyzePlugin(
(id) => clientEntryFileSet.add(id),
(id) => serverEntryFileSet.add(id)
),
],
// ssr: {
// // FIXME Without this, waku/router isn't considered to have client
// // entries, and "No client entry" error occurs.
// // Unless we fix this, RSC-capable packages aren't supported.
// // This also seems to cause problems with pnpm.
// // noExternal: ['@redwoodjs/web', '@redwoodjs/router'],
// },
build: {
write: false,
ssr: true,
rollupOptions: {
input: {
// entries: rwPaths.web.entryServer,
entries: path.join(rwPaths.web.src, 'entries.ts'),
},
},
},
})

const clientEntryFiles = Object.fromEntries(
Array.from(clientEntryFileSet).map((filename, i) => [`rsc${i}`, filename])
)
const serverEntryFiles = Object.fromEntries(
Array.from(serverEntryFileSet).map((filename, i) => [`rsf${i}`, filename])
)

console.log('clientEntryFileSet', Array.from(clientEntryFileSet))
console.log('serverEntryFileSet', Array.from(serverEntryFileSet))
console.log('clientEntryFiles', clientEntryFiles)
console.log('serverEntryFiles', serverEntryFiles)

const clientEntryPath = rwPaths.web.entryClient

if (!clientEntryPath) {
throw new Error(
'Vite client entry point not found. Please check that your project ' +
'has an entry.client.{jsx,tsx} file in the web/src directory.'
)
}

const clientBuildOutput = await viteBuild({
// ...configFileConfig,
root: rwPaths.web.src,
plugins: [
// TODO (RSC) Update index.html to include the entry.client.js script
// TODO (RSC) Do the above in the exp-rsc setup command
// {
// name: 'redwood-plugin-vite',

// // ---------- Bundle injection ----------
// // Used by rollup during build to inject the entrypoint
// // but note index.html does not come through as an id during dev
// transform: (code: string, id: string) => {
// if (
// existsSync(clientEntryPath) &&
// // TODO (RSC) Is this even needed? We throw if we can't find it above
// // TODO (RSC) Consider making this async (if we do need it)
// normalizePath(id) === normalizePath(rwPaths.web.html)
// ) {
// const newCode = code.replace(
// '</head>',
// '<script type="module" src="entry.client.jsx"></script></head>'
// )
//
// return { code: newCode, map: null }
// } else {
// // Returning null as the map preserves the original sourcemap
// return { code, map: null }
// }
// },
// },
react(),
rscIndexPlugin(),
],
build: {
outDir: rwPaths.web.dist,
emptyOutDir: true, // Needed because `outDir` is not inside `root`
// TODO (RSC) Enable this when we switch to a server-first approach
// emptyOutDir: false, // Already done when building server
rollupOptions: {
input: {
main: rwPaths.web.html,
...clientEntryFiles,
},
preserveEntrySignatures: 'exports-only',
},
manifest: 'build-manifest.json',
},
esbuild: {
logLevel: 'debug',
},
})

if (!('output' in clientBuildOutput)) {
throw new Error('Unexpected vite client build output')
}

const serverBuildOutput = await serverBuild(
// rwPaths.web.entryServer,
path.join(rwPaths.web.src, 'entries.ts'),
clientEntryFiles,
serverEntryFiles,
{}
)

const clientEntries: Record<string, string> = {}
for (const item of clientBuildOutput.output) {
const { name, fileName } = item
const entryFile =
name &&
serverBuildOutput.output.find(
(item) =>
'moduleIds' in item &&
item.moduleIds.includes(clientEntryFiles[name] as string)
)?.fileName
if (entryFile) {
clientEntries[entryFile] = fileName
}
}

console.log('clientEntries', clientEntries)

await fs.appendFile(
path.join(rwPaths.web.distServer, 'entries.js'),
`export const clientEntries=${JSON.stringify(clientEntries)};`
)

// // Step 1A: Generate the client bundle
// await buildWeb({ verbose })

// const rollupInput = {
// entries: rwPaths.web.entryServer,
// ...clientEntryFiles,
// ...serverEntryFiles,
// }

// Step 1B: Generate the server output
// await build({
// // TODO (RSC) I had this marked as 'FIXME'. I guess I just need to make
// // sure we still include it, or at least make it possible for users to pass
// // in their own config
// // configFile: viteConfig,
// ssr: {
// noExternal: Array.from(clientEntryFileSet).map(
// // TODO (RSC) I think the comment below is from waku. We don't care
// // about pnpm, do we? Does it also affect yarn?
// // FIXME this might not work with pnpm
// // TODO (RSC) No idea what's going on here
// (filename) => {
// const nodeModulesPath = path.join(rwPaths.base, 'node_modules')
// console.log('nodeModulesPath', nodeModulesPath)
// const relativePath = path.relative(nodeModulesPath, filename)
// console.log('relativePath', relativePath)
// console.log('first split', relativePath.split('/')[0])

// return relativePath.split('/')[0]
// }
// ),
// },
// build: {
// // Because we configure the root to be web/src, we need to go up one level
// outDir: rwPaths.web.distServer,
// // TODO (RSC) Maybe we should re-enable this. I can't remember anymore)
// // What does 'ssr' even mean?
// // ssr: rwPaths.web.entryServer,
// rollupOptions: {
// input: {
// // TODO (RSC) entries: rwPaths.web.entryServer,
// ...clientEntryFiles,
// ...serverEntryFiles,
// },
// output: {
// banner: (chunk) => {
// console.log('chunk', chunk)

// // HACK to bring directives to the front
// let code = ''

// if (chunk.moduleIds.some((id) => clientEntryFileSet.has(id))) {
// code += '"use client";'
// }

// if (chunk.moduleIds.some((id) => serverEntryFileSet.has(id))) {
// code += '"use server";'
// }

// console.log('code', code)
// return code
// },
// entryFileNames: (chunkInfo) => {
// console.log('chunkInfo', chunkInfo)

// // TODO (RSC) Don't hardcode 'entry.server'
// if (chunkInfo.name === 'entry.server') {
// return '[name].js'
// }

// return 'assets/[name].js'
// },
// },
// },
// },
// envFile: false,
// logLevel: verbose ? 'info' : 'warn',
// })

// Step 3: Generate route-manifest.json

// TODO When https://github.com/tc39/proposal-import-attributes and
// https://github.com/microsoft/TypeScript/issues/53656 have both landed we
// should try to do this instead:
// const clientBuildManifest: ViteBuildManifest = await import(
// path.join(getPaths().web.dist, 'build-manifest.json'),
// { with: { type: 'json' } }
// )
// NOTES:
// * There's a related babel plugin here
// https://babeljs.io/docs/babel-plugin-syntax-import-attributes
// * Included in `preset-env` if you set `shippedProposals: true`
// * We had this before, but with `assert` instead of `with`. We really
// should be using `with`. See motivation in issues linked above.
// * With `assert` and `@babel/plugin-syntax-import-assertions` the
// code compiled and ran properly, but Jest tests failed, complaining
// about the syntax.
const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json')
const buildManifestStr = await fs.readFile(manifestPath, 'utf-8')
const clientBuildManifest: ViteBuildManifest = JSON.parse(buildManifestStr)

// TODO (RSC) We don't have support for a router yet, so skip all routes
const routesList = [] as RouteSpec[] // getProjectRoutes()

// This is all a no-op for now
const routeManifest = routesList.reduce<RWRouteManifest>((acc, route) => {
acc[route.path] = {
name: route.name,
bundle: route.relativeFilePath
? clientBuildManifest[route.relativeFilePath].file
: null,
matchRegexString: route.matchRegexString,
// NOTE this is the path definition, not the actual path
// E.g. /blog/post/{id:Int}
pathDefinition: route.path,
hasParams: route.hasParams,
routeHooks: FIXME_constructRouteHookPath(route.routeHooks),
redirect: route.redirect
? {
to: route.redirect?.to,
permanent: false,
}
: null,
renderMode: route.renderMode,
}

return acc
}, {})

await fs.writeFile(rwPaths.web.routeManifest, JSON.stringify(routeManifest))
}

// TODO (STREAMING) Hacky work around because when you don't have a App.routeHook, esbuild doesn't create
// the pages folder in the dist/server/routeHooks directory.
// @MARK need to change to .mjs here if we use esm
const FIXME_constructRouteHookPath = (rhSrcPath: string | null | undefined) => {
const rwPaths = getPaths()
if (!rhSrcPath) {
return null
}

if (getAppRouteHook()) {
return path.relative(rwPaths.web.src, rhSrcPath).replace('.ts', '.js')
} else {
return path
.relative(path.join(rwPaths.web.src, 'pages'), rhSrcPath)
.replace('.ts', '.js')
}
}

if (require.main === module) {
const verbose = process.argv.includes('--verbose')
buildFeServer({ verbose })
}
Loading

0 comments on commit f22dfbe

Please sign in to comment.