From 5adb761a3c2eb19f10627a546eb3a590d035a9c0 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 22 May 2024 02:31:31 +0200 Subject: [PATCH] RSC Client Router (#10557) --- .../test-project-rsa/web/src/Routes.tsx | 11 +- .../test-project-rsa/web/src/entry.server.tsx | 10 +- .../NavigationLayout/NavigationLayout.tsx | 6 +- .../web/src/Routes.tsx | 20 +-- .../web/src/ServerRoutes.tsx | 85 ------------ .../EmptyUsersCell/EmptyUsersCell.tsx | 8 +- .../ReadFileServerCell/ReadFileServerCell.tsx | 44 ++++++ .../web/src/entry.server.tsx | 4 +- .../NavigationLayout/NavigationLayout.tsx | 3 + .../commands/experimental/setupRscHandler.js | 24 ++++ .../rsc/NavigationLayout.tsx.template | 6 +- .../templates/rsc/Routes.tsx.template | 10 +- .../templates/rsc/entry.client.tsx.template | 35 +++++ .../templates/rsc/entry.server.tsx.template | 10 +- packages/router/src/dummyComponent.ts | 3 + packages/vite/package.json | 5 + packages/vite/src/ClientRouter.tsx | 108 +++++++++++++++ packages/vite/src/ServerRouter.ts | 1 + packages/vite/src/lib/entries.ts | 1 + ...vite-plugin-rsc-route-auto-loader.test.mts | 19 ++- .../vite-plugin-rsc-routes-auto-loader.ts | 126 +++++++++++++---- .../plugins/vite-plugin-rsc-routes-imports.ts | 131 ++++++++++++++++++ packages/vite/src/rsc/rscBuildForServer.ts | 4 +- packages/vite/src/rsc/rscWorker.ts | 77 ++++++---- packages/vite/tsconfig.json | 1 + .../tests/rsc-kitchen-sink.spec.ts | 10 ++ 26 files changed, 584 insertions(+), 178 deletions(-) delete mode 100644 __fixtures__/test-project-rsc-kitchen-sink/web/src/ServerRoutes.tsx create mode 100644 __fixtures__/test-project-rsc-kitchen-sink/web/src/components/ReadFileServerCell/ReadFileServerCell.tsx create mode 100644 packages/cli/src/commands/experimental/templates/rsc/entry.client.tsx.template create mode 100644 packages/router/src/dummyComponent.ts create mode 100644 packages/vite/src/ClientRouter.tsx create mode 100644 packages/vite/src/ServerRouter.ts create mode 100644 packages/vite/src/plugins/vite-plugin-rsc-routes-imports.ts diff --git a/__fixtures__/test-project-rsa/web/src/Routes.tsx b/__fixtures__/test-project-rsa/web/src/Routes.tsx index 595c80207883..89907555e07d 100644 --- a/__fixtures__/test-project-rsa/web/src/Routes.tsx +++ b/__fixtures__/test-project-rsa/web/src/Routes.tsx @@ -7,14 +7,19 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route, Set } from '@redwoodjs/router' +import { Route } from '@redwoodjs/router/dist/Route' +import { Set } from '@redwoodjs/router/dist/Set' + +// @ts-expect-error - ESM issue. RW projects need to be ESM to properly pick up +// on the types here +import { Router } from '@redwoodjs/vite/Router' import NavigationLayout from './layouts/NavigationLayout/NavigationLayout' import NotFoundPage from './pages/NotFoundPage/NotFoundPage' -const Routes = () => { +const Routes = ({ location }: { location?: any }) => { return ( - + diff --git a/__fixtures__/test-project-rsa/web/src/entry.server.tsx b/__fixtures__/test-project-rsa/web/src/entry.server.tsx index 32fcd961f471..1621bd5bd233 100644 --- a/__fixtures__/test-project-rsa/web/src/entry.server.tsx +++ b/__fixtures__/test-project-rsa/web/src/entry.server.tsx @@ -1,16 +1,22 @@ import type { TagDescriptor } from '@redwoodjs/web/dist/components/htmlTags' import { Document } from './Document' +import Routes from './Routes' interface Props { css: string[] meta?: TagDescriptor[] + location: { + pathname: string + hash?: string + search?: string + } } -export const ServerEntry: React.FC = ({ css, meta }) => { +export const ServerEntry: React.FC = ({ css, meta, location }) => { return ( -
App
+
) } diff --git a/__fixtures__/test-project-rsa/web/src/layouts/NavigationLayout/NavigationLayout.tsx b/__fixtures__/test-project-rsa/web/src/layouts/NavigationLayout/NavigationLayout.tsx index 4f13e197309a..7e039d3a6f37 100644 --- a/__fixtures__/test-project-rsa/web/src/layouts/NavigationLayout/NavigationLayout.tsx +++ b/__fixtures__/test-project-rsa/web/src/layouts/NavigationLayout/NavigationLayout.tsx @@ -1,4 +1,4 @@ -import { Link, routes } from '@redwoodjs/router' +import { namedRoutes as routes } from '@redwoodjs/router/dist/namedRoutes' import './NavigationLayout.css' @@ -6,6 +6,10 @@ type NavigationLayoutProps = { children?: React.ReactNode } +const Link = (props: any) => { + return {props.children} +} + const NavigationLayout = ({ children }: NavigationLayoutProps) => { return (
diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/Routes.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/Routes.tsx index 2f71f3ec9501..3bfb6dfbb05b 100644 --- a/__fixtures__/test-project-rsc-kitchen-sink/web/src/Routes.tsx +++ b/__fixtures__/test-project-rsc-kitchen-sink/web/src/Routes.tsx @@ -7,30 +7,34 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route, Set } from '@redwoodjs/router' +import { Route } from '@redwoodjs/router/dist/Route' +import { Set } from '@redwoodjs/router/dist/Set' + +// @ts-expect-error - ESM issue. RW projects need to be ESM to properly pick up +// on the types here +import { Router } from '@redwoodjs/vite/Router' import NavigationLayout from './layouts/NavigationLayout/NavigationLayout' import ScaffoldLayout from './layouts/ScaffoldLayout/ScaffoldLayout' -import NotFoundPage from './pages/NotFoundPage/NotFoundPage' -const Routes = () => { +const Routes = ({ location }: { location?: any }) => { return ( - - + + - {/* - */} + + - {/* */} + diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/ServerRoutes.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/ServerRoutes.tsx deleted file mode 100644 index 1ba9f57c8638..000000000000 --- a/__fixtures__/test-project-rsc-kitchen-sink/web/src/ServerRoutes.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// In this file, all Page components from 'src/pages` are auto-imported. Nested -// directories are supported, and should be uppercase. Each subdirectory will be -// prepended onto the component name. -// -// Examples: -// -// 'src/pages/HomePage/HomePage.js' -> HomePage -// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage - -import { Route } from '@redwoodjs/router/dist/Route' -import { Router } from '@redwoodjs/router/dist/server-router' -import { Set } from '@redwoodjs/router/dist/Set' - -import NavigationLayout from './layouts/NavigationLayout/NavigationLayout' -import ScaffoldLayout from './layouts/ScaffoldLayout/ScaffoldLayout' -import AboutPage from './pages/AboutPage/AboutPage' -import EmptyUserEmptyUsersPage from './pages/EmptyUser/EmptyUsersPage/EmptyUsersPage' -import EmptyUserNewEmptyUserPage from './pages/EmptyUser/NewEmptyUserPage/NewEmptyUserPage' -import HomePage from './pages/HomePage/HomePage' -import MultiCellPage from './pages/MultiCellPage/MultiCellPage' -import UserExampleNewUserExamplePage from './pages/UserExample/NewUserExamplePage/NewUserExamplePage' -import UserExampleUserExamplePage from './pages/UserExample/UserExamplePage/UserExamplePage' -import UserExampleUserExamplesPage from './pages/UserExample/UserExamplesPage/UserExamplesPage' - -const NotFoundPage = () => { - return
Not Found
-} - -const Routes = ({ location }) => { - return ( - - - - - - - - - - - - - - - - - - - - ) -} - -export default Routes diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/EmptyUser/EmptyUsersCell/EmptyUsersCell.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/EmptyUser/EmptyUsersCell/EmptyUsersCell.tsx index 786f9aa81b9d..51c1c78e0ea5 100644 --- a/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/EmptyUser/EmptyUsersCell/EmptyUsersCell.tsx +++ b/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/EmptyUser/EmptyUsersCell/EmptyUsersCell.tsx @@ -2,7 +2,9 @@ import type { FindEmptyUsers, FindEmptyUsersVariables } from 'types/graphql' -import { Link, routes } from '@redwoodjs/router' +// TODO (RSC): Use Link from '@redwoodjs/router' +// import { Link, routes } from '@redwoodjs/router' +import { routes } from '@redwoodjs/router' import type { CellSuccessProps, CellFailureProps, @@ -11,6 +13,10 @@ import type { import EmptyUsers from 'src/components/EmptyUser/EmptyUsers' +const Link = (props: any) => { + return {props.children} +} + export const QUERY: TypedDocumentNode = gql` query FindEmptyUsers { diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/ReadFileServerCell/ReadFileServerCell.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/ReadFileServerCell/ReadFileServerCell.tsx new file mode 100644 index 000000000000..1cc81d8850b4 --- /dev/null +++ b/__fixtures__/test-project-rsc-kitchen-sink/web/src/components/ReadFileServerCell/ReadFileServerCell.tsx @@ -0,0 +1,44 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' + +export const data = async () => { + const srcPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'src', + 'components', + 'ReadFileServerCell', + 'ReadFileServerCell.tsx' + ) + + const file = fs.readFileSync(srcPath, 'utf-8') + + return { file } +} + +export const Loading = () =>
Reading file...
+ +export const Empty = () =>
Empty file
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +type SuccessProps = CellSuccessProps>> +export const Success = ({ file }: SuccessProps) => { + return ( +
+

The source of this server cell:

+
+        {file}
+      
+
+ ) +} diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/entry.server.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/entry.server.tsx index 5636e61d4df6..1621bd5bd233 100644 --- a/__fixtures__/test-project-rsc-kitchen-sink/web/src/entry.server.tsx +++ b/__fixtures__/test-project-rsc-kitchen-sink/web/src/entry.server.tsx @@ -1,7 +1,7 @@ import type { TagDescriptor } from '@redwoodjs/web/dist/components/htmlTags' import { Document } from './Document' -import ServerRoutes from './ServerRoutes' +import Routes from './Routes' interface Props { css: string[] @@ -16,7 +16,7 @@ interface Props { export const ServerEntry: React.FC = ({ css, meta, location }) => { return ( - + ) } diff --git a/__fixtures__/test-project-rsc-kitchen-sink/web/src/layouts/NavigationLayout/NavigationLayout.tsx b/__fixtures__/test-project-rsc-kitchen-sink/web/src/layouts/NavigationLayout/NavigationLayout.tsx index 2fef86394e40..a10daa0b7d1c 100644 --- a/__fixtures__/test-project-rsc-kitchen-sink/web/src/layouts/NavigationLayout/NavigationLayout.tsx +++ b/__fixtures__/test-project-rsc-kitchen-sink/web/src/layouts/NavigationLayout/NavigationLayout.tsx @@ -1,5 +1,7 @@ import { namedRoutes as routes } from '@redwoodjs/router/dist/namedRoutes' +import ReadFileServerCell from 'src/components/ReadFileServerCell' + import './NavigationLayout.css' type NavigationLayoutProps = { @@ -34,6 +36,7 @@ const NavigationLayout = ({ children, rnd }: NavigationLayoutProps) => {
{Math.round(rnd * 100)}
+

Layout end


{children}
diff --git a/packages/cli/src/commands/experimental/setupRscHandler.js b/packages/cli/src/commands/experimental/setupRscHandler.js index 6e75a69ed7b0..1f529453baa3 100644 --- a/packages/cli/src/commands/experimental/setupRscHandler.js +++ b/packages/cli/src/commands/experimental/setupRscHandler.js @@ -77,6 +77,30 @@ export const handler = async ({ force, verbose }) => { }, options: { persistentOutput: true }, }, + { + title: `Overwriting entry.client${ext}...`, + task: async () => { + const entryClientTemplate = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'rsc', + 'entry.client.tsx.template', + ), + 'utf-8', + ) + const entryClientContent = isTypeScriptProject() + ? entryClientTemplate + : await transformTSToJS( + rwPaths.web.entryClient, + entryClientTemplate, + ) + + writeFile(rwPaths.web.entryClient, entryClientContent, { + overwriteExisting: true, + }) + }, + }, { title: `Overwriting entry.server${ext}...`, task: async () => { diff --git a/packages/cli/src/commands/experimental/templates/rsc/NavigationLayout.tsx.template b/packages/cli/src/commands/experimental/templates/rsc/NavigationLayout.tsx.template index 4f13e197309a..43e92777c513 100644 --- a/packages/cli/src/commands/experimental/templates/rsc/NavigationLayout.tsx.template +++ b/packages/cli/src/commands/experimental/templates/rsc/NavigationLayout.tsx.template @@ -1,7 +1,11 @@ -import { Link, routes } from '@redwoodjs/router' +import { namedRoutes as routes } from '@redwoodjs/router/dist/namedRoutes' import './NavigationLayout.css' +const Link = (props: any) => { + return {props.children} +} + type NavigationLayoutProps = { children?: React.ReactNode } diff --git a/packages/cli/src/commands/experimental/templates/rsc/Routes.tsx.template b/packages/cli/src/commands/experimental/templates/rsc/Routes.tsx.template index 2b99b964f0d1..0fdee816ca68 100644 --- a/packages/cli/src/commands/experimental/templates/rsc/Routes.tsx.template +++ b/packages/cli/src/commands/experimental/templates/rsc/Routes.tsx.template @@ -7,14 +7,18 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route, Set } from '@redwoodjs/router' +import { Route } from '@redwoodjs/router/dist/Route' +import { Set } from '@redwoodjs/router/dist/Set' +// @ts-expect-error - ESM issue. RW projects need to be ESM to properly pick up +// on the types here +import { Router } from '@redwoodjs/vite/Router' import NavigationLayout from 'src/layouts/NavigationLayout' import NotFoundPage from 'src/pages/NotFoundPage' -const Routes = () => { +const Routes = ({ location }: { location?: any }) => { return ( - + diff --git a/packages/cli/src/commands/experimental/templates/rsc/entry.client.tsx.template b/packages/cli/src/commands/experimental/templates/rsc/entry.client.tsx.template new file mode 100644 index 000000000000..8247c96a5566 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/rsc/entry.client.tsx.template @@ -0,0 +1,35 @@ +import { hydrateRoot, createRoot } from 'react-dom/client' + +import App from './App' +import Routes from './Routes' + +/** + * When `#redwood-app` isn't empty then it's very likely that you're using + * pre-rendering. So React attaches event listeners to the existing markup + * rather than replacing it. + * https://react.dev/reference/react-dom/client/hydrateRoot + */ +const redwoodAppElement = document.getElementById('redwood-app') + +if (!redwoodAppElement) { + throw new Error( + "Could not find an element with ID 'redwood-app'. Please ensure it " + + "exists in your 'web/src/index.html' file." + ) +} + +if (redwoodAppElement.children?.length > 0) { + hydrateRoot( + redwoodAppElement, + + + + ) +} else { + const root = createRoot(redwoodAppElement) + root.render( + + + + ) +} diff --git a/packages/cli/src/commands/experimental/templates/rsc/entry.server.tsx.template b/packages/cli/src/commands/experimental/templates/rsc/entry.server.tsx.template index 32fcd961f471..1621bd5bd233 100644 --- a/packages/cli/src/commands/experimental/templates/rsc/entry.server.tsx.template +++ b/packages/cli/src/commands/experimental/templates/rsc/entry.server.tsx.template @@ -1,16 +1,22 @@ import type { TagDescriptor } from '@redwoodjs/web/dist/components/htmlTags' import { Document } from './Document' +import Routes from './Routes' interface Props { css: string[] meta?: TagDescriptor[] + location: { + pathname: string + hash?: string + search?: string + } } -export const ServerEntry: React.FC = ({ css, meta }) => { +export const ServerEntry: React.FC = ({ css, meta, location }) => { return ( -
App
+
) } diff --git a/packages/router/src/dummyComponent.ts b/packages/router/src/dummyComponent.ts new file mode 100644 index 000000000000..0507e0990aff --- /dev/null +++ b/packages/router/src/dummyComponent.ts @@ -0,0 +1,3 @@ +// This is used by vite-plugin-rsc-routes-auto-loader + +export default () => null diff --git a/packages/vite/package.json b/packages/vite/package.json index 5fe442257ce3..cbc682e4ea4a 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -22,6 +22,11 @@ "types": "./dist/clientSsr.d.ts", "default": "./dist/clientSsr.js" }, + "./Router": { + "types": "./dist/ClientRouter.d.ts", + "react-server": "./dist/ServerRouter.js", + "default": "./dist/ClientRouter.js" + }, "./buildFeServer": { "types": "./dist/buildFeServer.d.ts", "default": "./dist/buildFeServer.js" diff --git a/packages/vite/src/ClientRouter.tsx b/packages/vite/src/ClientRouter.tsx new file mode 100644 index 000000000000..9a3b46628958 --- /dev/null +++ b/packages/vite/src/ClientRouter.tsx @@ -0,0 +1,108 @@ +// TODO (RSC): This should live in @redwoodjs/router but I didn't want to add +// `react-server-dom-webpack` as a dependency there. We should first figure out +// what to do about rscFetch here vs renderFromRscServer and see if maybe that +// one should live somewhere else where @redwoodjs/router can import from + +import React, { useMemo } from 'react' + +import type { Options } from 'react-server-dom-webpack/client' +import { createFromFetch, encodeReply } from 'react-server-dom-webpack/client' + +import { analyzeRoutes } from '@redwoodjs/router/dist/analyzeRoutes' +import { LocationProvider, useLocation } from '@redwoodjs/router/dist/location' +import { namedRoutes } from '@redwoodjs/router/dist/namedRoutes' +import type { RouterProps } from '@redwoodjs/router/dist/router' + +const BASE_PATH = '/rw-rsc/' + +function rscFetch(rscId: string, props: Record = {}) { + const searchParams = new URLSearchParams() + searchParams.set('props', JSON.stringify(props)) + + // TODO (RSC): During SSR we should not fetch (Is this function really + // called during SSR?) + const response = fetch(BASE_PATH + rscId + '?' + searchParams, { + headers: { + 'rw-rsc': '1', + }, + }) + + const options: Options = { + // React will hold on to `callServer` and use that when it detects a + // server action is invoked (like `action={onSubmit}` in a
+ // element). So for now at least we need to send it with every RSC + // request, so React knows what `callServer` method to use for server + // actions inside the RSC. + callServer: async function (rsfId: string, args: unknown[]) { + // `args` is often going to be an array with just a single element, + // and that element will be FormData + console.log('ClientRouter.ts :: callServer rsfId', rsfId, 'args', args) + + const searchParams = new URLSearchParams() + searchParams.set('action_id', rsfId) + const id = '_' + + const response = fetch(BASE_PATH + id + '?' + searchParams, { + method: 'POST', + body: await encodeReply(args), + headers: { + 'rw-rsc': '1', + }, + }) + + // I'm not sure this recursive use of `options` is needed. I briefly + // tried without it, and things seemed to work. But keeping it for + // now, until we learn more. + const data = createFromFetch(response, options) + + return data + }, + } + + return createFromFetch(response, options) +} + +let routes: Thenable | null = null + +export const Router = ({ paramTypes, children }: RouterProps) => { + return ( + // Wrap it in the provider so that useLocation can be used + + + {children} + + + ) +} + +const LocationAwareRouter = ({ paramTypes, children }: RouterProps) => { + const location = useLocation() + + const { namedRoutesMap } = useMemo(() => { + return analyzeRoutes(children, { + currentPathName: location.pathname, + // @TODO We haven't handled this with SSR/Streaming yet. + // May need a babel plugin to extract userParamTypes from Routes.tsx + userParamTypes: paramTypes, + }) + }, [location.pathname, children, paramTypes]) + + // Assign namedRoutes so it can be imported like import {routes} from 'rwjs/router' + // Note that the value changes at runtime + Object.assign(namedRoutes, namedRoutesMap) + + // TODO (RSC): Refetch when the location changes + // It currently works because we always do a full page refresh, but that's + // not what we really want to do) + if (!routes) { + routes = rscFetch('__rwjs__Routes', { + // All we need right now is the pathname. Plus, `location` is a URL + // object, and it doesn't JSON.stringify well. Basically all you end up + // with is the href. That's why we manually construct the object here + // instead of just passing `location`. + location: { pathname: location.pathname }, + }) + } + + return routes +} diff --git a/packages/vite/src/ServerRouter.ts b/packages/vite/src/ServerRouter.ts new file mode 100644 index 000000000000..2aa732f5e99f --- /dev/null +++ b/packages/vite/src/ServerRouter.ts @@ -0,0 +1 @@ +export { Router } from '@redwoodjs/router/dist/server-router' diff --git a/packages/vite/src/lib/entries.ts b/packages/vite/src/lib/entries.ts index b1f1711414da..aceee8101137 100644 --- a/packages/vite/src/lib/entries.ts +++ b/packages/vite/src/lib/entries.ts @@ -32,6 +32,7 @@ export function getEntries() { throw new Error('Server Entry file not found') } entries['__rwjs__ServerEntry'] = serverEntry + entries['__rwjs__Routes'] = getPaths().web.routes return entries } diff --git a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts b/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts index 1003a85d3da7..1caa19de1a3f 100644 --- a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts +++ b/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts @@ -95,18 +95,17 @@ describe('rscRoutesAutoLoader', () => { // - The import of `renderFromRscServer` from `@redwoodjs/vite/client` // - The call to `renderFromRscServer` for each page that wasn't already imported expect(output).toMatchInlineSnapshot(` - "import { renderFromRscServer } from "@redwoodjs/vite/client"; - const EmptyUserNewEmptyUserPage = renderFromRscServer("EmptyUserNewEmptyUserPage"); - const EmptyUserEmptyUsersPage = renderFromRscServer("EmptyUserEmptyUsersPage"); - const EmptyUserEmptyUserPage = renderFromRscServer("EmptyUserEmptyUserPage"); - const EmptyUserEditEmptyUserPage = renderFromRscServer("EmptyUserEditEmptyUserPage"); - const HomePage = renderFromRscServer("HomePage"); - const FatalErrorPage = renderFromRscServer("FatalErrorPage"); - const AboutPage = renderFromRscServer("AboutPage"); + "const EmptyUserNewEmptyUserPage = () => null; + const EmptyUserEmptyUsersPage = () => null; + const EmptyUserEmptyUserPage = () => null; + const EmptyUserEditEmptyUserPage = () => null; + const HomePage = () => null; + const FatalErrorPage = () => null; + const AboutPage = () => null; import { jsx, jsxs } from "react/jsx-runtime"; import { Router, Route, Set } from "@redwoodjs/router"; - import NavigationLayout from "./layouts/NavigationLayout/NavigationLayout"; - import ScaffoldLayout from "./layouts/ScaffoldLayout/ScaffoldLayout"; + import NavigationLayout from "@redwoodjs/router/dist/dummyComponent"; + import ScaffoldLayout from "@redwoodjs/router/dist/dummyComponent"; import NotFoundPage from "./pages/NotFoundPage/NotFoundPage"; const Routes = () => { return /* @__PURE__ */jsxs(Router, { diff --git a/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts b/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts index 1d17e90a35e3..fa5de5d8b3c8 100644 --- a/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts +++ b/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts @@ -77,13 +77,9 @@ export function rscRoutesAutoLoader(): Plugin { // We have to handle the loading of routes in two different ways depending on if // we are doing SSR or not. During SSR we want to load files directly whereas on // the client we have to fetch things over the network. + // TODO (RSC): ↑ Update comment to reflect what's actually going on const isSsr = options?.ssr ?? false - const loadFunctionModule = isSsr - ? '@redwoodjs/vite/clientSsr' - : '@redwoodjs/vite/client' - const loadFunctionName = isSsr ? 'renderFromDist' : 'renderFromRscServer' - // Parse the code as AST const ext = path.extname(id) const plugins: any[] = [] @@ -95,10 +91,24 @@ export function rscRoutesAutoLoader(): Plugin { plugins, }) - // We have to filter out any pages which the user has already explicitly imported - // in the routes file otherwise there would be conflicts. + // We have to filter out any pages which the user has already explicitly + // imported in the routes file otherwise there would be conflicts. const importedNames = new Set() + // Store a reference to all default imports so we can update Set wrapper + // imports later + // TODO (RSC): Make this all imports, not just default imports. But have to + // figure out how to handle something like + // `import { MyLayout, SomethingElse } from './myLayout'` + // and turning it into + // `import { SomethingElse } from './myLayout'` + // `import { MyLayout } from '@redwoodjs/router/dist/dummyComponent'` + // and also + // `import MyLayout, { SomethingElse } from './myLayout'` + const allImports = new Map() + // All components used as Set wrappers + const wrappers = new Set() + traverse(ast, { ImportDeclaration(path) { const importPath = path.node.source.value @@ -118,6 +128,45 @@ export function rscRoutesAutoLoader(): Plugin { if (userImportRelativePath && defaultSpecifier) { importedNames.add(defaultSpecifier.local.name) } + + path.node.specifiers.forEach((specifier) => { + allImports.set(specifier.local.name, path.node) + }) + }, + JSXElement() { + // The file is already transformed from JSX to `jsx()` and `jsxs()` + // calls when this plugin executes, so this will never get called + }, + CallExpression(path) { + if (isSsr) { + return + } + + if ( + (t.isIdentifier(path.node.callee, { name: 'jsxs' }) || + t.isIdentifier(path.node.callee, { name: 'jsx' })) && + t.isIdentifier(path.node.arguments[0]) && + path.node.arguments[0].name === 'Set' + ) { + const jsxArgs = path.node.arguments + if (t.isObjectExpression(jsxArgs[1])) { + const wrapProp = jsxArgs[1].properties.find( + (prop): prop is t.ObjectProperty => + t.isObjectProperty(prop) && + t.isIdentifier(prop.key, { name: 'wrap' }), + ) + + if (t.isArrayExpression(wrapProp?.value)) { + wrapProp.value.elements.forEach((element) => { + if (t.isIdentifier(element)) { + wrappers.add(element.name) + } + }) + } else if (t.isIdentifier(wrapProp?.value)) { + wrappers.add(wrapProp.value.name) + } + } + } }, }) @@ -125,33 +174,54 @@ export function rscRoutesAutoLoader(): Plugin { (page) => !importedNames.has(page.importName), ) + wrappers.forEach((wrapper) => { + const wrapperImport = allImports.get(wrapper) + + if (wrapperImport) { + wrapperImport.source.value = '@redwoodjs/router/dist/dummyComponent' + } + }) + // Insert the page loading into the code for (const page of nonImportedPages) { + if (isSsr) { + ast.program.body.unshift( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(page.constName), + t.callExpression(t.identifier('renderFromDist'), [ + t.stringLiteral(page.constName), + ]), + ), + ]), + ) + } else { + ast.program.body.unshift( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(page.constName), + t.arrowFunctionExpression([], t.nullLiteral()), + ), + ]), + ) + } + } + + if (isSsr) { + // Insert an import for the load function we need ast.program.body.unshift( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(page.constName), - t.callExpression(t.identifier(loadFunctionName), [ - t.stringLiteral(page.constName), - ]), - ), - ]), + t.importDeclaration( + [ + t.importSpecifier( + t.identifier('renderFromDist'), + t.identifier('renderFromDist'), + ), + ], + t.stringLiteral('@redwoodjs/vite/clientSsr'), + ), ) } - // Insert an import for the load function we need - ast.program.body.unshift( - t.importDeclaration( - [ - t.importSpecifier( - t.identifier(loadFunctionName), - t.identifier(loadFunctionName), - ), - ], - t.stringLiteral(loadFunctionModule), - ), - ) - return generate(ast).code }, } diff --git a/packages/vite/src/plugins/vite-plugin-rsc-routes-imports.ts b/packages/vite/src/plugins/vite-plugin-rsc-routes-imports.ts new file mode 100644 index 000000000000..2d7ca966a5fe --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-rsc-routes-imports.ts @@ -0,0 +1,131 @@ +import path from 'path' + +import generate from '@babel/generator' +import { parse as babelParse } from '@babel/parser' +import traverse from '@babel/traverse' +import * as t from '@babel/types' +import type { Plugin } from 'vite' +import { normalizePath } from 'vite' + +import type { PagesDependency } from '@redwoodjs/project-config' +import { + ensurePosixPath, + getPaths, + importStatementPath, + processPagesDir, +} from '@redwoodjs/project-config' + +const getPathRelativeToSrc = (maybeAbsolutePath: string) => { + // If the path is already relative + if (!path.isAbsolute(maybeAbsolutePath)) { + return maybeAbsolutePath + } + + return `./${path.relative(getPaths().web.src, maybeAbsolutePath)}` +} + +const withRelativeImports = (page: PagesDependency) => { + return { + ...page, + relativeImport: ensurePosixPath(getPathRelativeToSrc(page.importPath)), + } +} + +export function rscRoutesImports(): Plugin { + // Vite IDs are always normalized and so we avoid windows path issues + // by normalizing the path here. + const routesFileId = normalizePath(getPaths().web.routes) + + // Get the current pages + // @NOTE: This var gets mutated inside the visitors + const pages = processPagesDir().map(withRelativeImports) + + // Currently processPagesDir() can return duplicate entries when there are multiple files + // ending in Page in the individual page directories. This will cause an error upstream. + // Here we check for duplicates and throw a more helpful error message. + const duplicatePageImportNames = new Set() + const sortedPageImportNames = pages.map((page) => page.importName).sort() + for (let i = 0; i < sortedPageImportNames.length - 1; i++) { + if (sortedPageImportNames[i + 1] === sortedPageImportNames[i]) { + duplicatePageImportNames.add(sortedPageImportNames[i]) + } + } + if (duplicatePageImportNames.size > 0) { + const pageNames = Array.from(duplicatePageImportNames) + .map((name) => `'${name}'`) + .join(', ') + + throw new Error( + "Unable to find only a single file ending in 'Page.{js,jsx,ts,tsx}' in " + + `the following page directories: ${pageNames}`, + ) + } + + return { + name: 'rsc-routes-imports', + transform: async function (code, id) { + // We only care about the routes file + if (id !== routesFileId) { + return null + } + + // If we have no pages then we have no reason to do anything here + if (pages.length === 0) { + return null + } + + // Parse the code as AST + const ext = path.extname(id) + const plugins: any[] = [] + if (ext === '.jsx') { + plugins.push('jsx') + } + const ast = babelParse(code, { + sourceType: 'unambiguous', + plugins, + }) + + // We have to filter out any pages which the user has already explicitly + // imported in the routes file otherwise there would be conflicts. + const importedNames = new Set() + + traverse(ast, { + ImportDeclaration(path) { + const importPath = path.node.source.value + + if (importPath === null) { + return + } + + const userImportRelativePath = getPathRelativeToSrc( + importStatementPath(path.node.source?.value), + ) + + const defaultSpecifier = path.node.specifiers.filter((specifier) => + t.isImportDefaultSpecifier(specifier), + )[0] + + if (userImportRelativePath && defaultSpecifier) { + importedNames.add(defaultSpecifier.local.name) + } + }, + }) + + const nonImportedPages = pages.filter((page) => { + return !importedNames.has(page.importName) + }) + + // Insert the page import into the code + for (const page of nonImportedPages) { + ast.program.body.unshift( + t.importDeclaration( + [t.importDefaultSpecifier(t.identifier(page.importName))], + t.stringLiteral(page.importPath), + ), + ) + } + + return generate(ast).code + }, + } +} diff --git a/packages/vite/src/rsc/rscBuildForServer.ts b/packages/vite/src/rsc/rscBuildForServer.ts index fbf30cf7541a..5935fd983f1d 100644 --- a/packages/vite/src/rsc/rscBuildForServer.ts +++ b/packages/vite/src/rsc/rscBuildForServer.ts @@ -4,7 +4,7 @@ import { getPaths } from '@redwoodjs/project-config' import { getEntries } from '../lib/entries.js' import { onWarn } from '../lib/onWarn.js' -import { rscRoutesAutoLoader } from '../plugins/vite-plugin-rsc-routes-auto-loader.js' +import { rscRoutesImports } from '../plugins/vite-plugin-rsc-routes-imports.js' import { rscTransformUseClientPlugin } from '../plugins/vite-plugin-rsc-transform-client.js' import { rscTransformUseServerPlugin } from '../plugins/vite-plugin-rsc-transform-server.js' @@ -67,7 +67,7 @@ export async function rscBuildForServer( // (It does other things as well, but that's why it needs clientEntryFiles) rscTransformUseClientPlugin(clientEntryFiles), rscTransformUseServerPlugin(), - rscRoutesAutoLoader(), + rscRoutesImports(), ], build: { // TODO (RSC): Remove `minify: false` when we don't need to debug as often diff --git a/packages/vite/src/rsc/rscWorker.ts b/packages/vite/src/rsc/rscWorker.ts index 253cadfad46c..76cae41115af 100644 --- a/packages/vite/src/rsc/rscWorker.ts +++ b/packages/vite/src/rsc/rscWorker.ts @@ -3,10 +3,11 @@ // `--condition react-server`. If we did try to do that the main process // couldn't do SSR because it would be missing client-side React functions // like `useState` and `createContext`. -import { Buffer } from 'node:buffer' +import type { Buffer } from 'node:buffer' import { Server } from 'node:http' import path from 'node:path' -import { Transform, Writable } from 'node:stream' +// import { Transform, Writable } from 'node:stream' +import { Writable } from 'node:stream' import { parentPort } from 'node:worker_threads' import { createElement } from 'react' @@ -232,6 +233,8 @@ const getFunctionComponent = async (rscId: string) => { entryModule = getEntries()[rscId] } else { const serverEntries = await getEntriesFromDist() + console.log('rscWorker.ts serverEntries', serverEntries) + entryModule = path.join(getPaths().web.distRsc, serverEntries[rscId]) } @@ -398,12 +401,22 @@ async function renderRsc(input: RenderInput): Promise { const config = await getViteConfig() + if (input.rscId === '__rwjs__Routes') { + const serverRoutes = await getFunctionComponent('__rwjs__Routes') + + return renderToPipeableStream( + createElement(serverRoutes, input.props), + getBundlerConfig(config), + ) + } + const component = await getFunctionComponent(input.rscId) return renderToPipeableStream( createElement(component, input.props), getBundlerConfig(config), - ).pipe(transformRsfId(config.root)) + ) + // ).pipe(transformRsfId(config.root)) } interface SerializedFormData { @@ -425,7 +438,7 @@ async function handleRsa(input: RenderInput): Promise { const config = await getViteConfig() const [fileId, name] = input.rsfId.split('#') - const fname = path.join(config.root, fileId) + const fname = fileId // path.join(config.root, fileId) console.log('Server Action, fileId', fileId, 'name', name, 'fname', fname) const module = await loadServerFile(fname) @@ -452,29 +465,33 @@ async function handleRsa(input: RenderInput): Promise { // HACK Patching stream is very fragile. // TODO (RSC): Sanitize prefixToRemove to make sure it's safe to use in a // RegExp (CodeQL is complaining on GitHub) -function transformRsfId(prefixToRemove: string) { - // Should be something like /home/runner/work/redwood/test-project-rsa - console.log('prefixToRemove', prefixToRemove) - - return new Transform({ - transform(chunk, encoding, callback) { - if (encoding !== ('buffer' as any)) { - throw new Error('Unknown encoding') - } - const data = chunk.toString() - const lines = data.split('\n') - console.log('lines', lines) - let changed = false - for (let i = 0; i < lines.length; ++i) { - const match = lines[i].match( - new RegExp(`^([0-9]+):{"id":"${prefixToRemove}(.*?)"(.*)$`), - ) - if (match) { - lines[i] = `${match[1]}:{"id":"${match[2]}"${match[3]}` - changed = true - } - } - callback(null, changed ? Buffer.from(lines.join('\n')) : chunk) - }, - }) -} +// TODO (RSC): Figure out if this is needed. Seems to remove config.root +// but then we add it back again in handleRsa() +// function transformRsfId(prefixToRemove: string) { +// // Should be something like /home/runner/work/redwood/test-project-rsa +// console.log('prefixToRemove', prefixToRemove) + +// return new Transform({ +// transform(chunk, encoding, callback) { +// if (encoding !== ('buffer' as any)) { +// throw new Error('Unknown encoding') +// } + +// const data = chunk.toString() +// const lines = data.split('\n') +// console.log('lines', lines) + +// let changed = false +// for (let i = 0; i < lines.length; ++i) { +// const match = lines[i].match( +// new RegExp(`^([0-9]+):{"id":"${prefixToRemove}(.*?)"(.*)$`), +// ) +// if (match) { +// lines[i] = `${match[1]}:{"id":"${match[2]}"${match[3]}` +// changed = true +// } +// } +// callback(null, changed ? Buffer.from(lines.join('\n')) : chunk) +// }, +// }) +// } diff --git a/packages/vite/tsconfig.json b/packages/vite/tsconfig.json index bde1e04e3228..7a14ff97cc24 100644 --- a/packages/vite/tsconfig.json +++ b/packages/vite/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../internal" }, { "path": "../project-config" }, + { "path": "../router" }, { "path": "../web" } ] } diff --git a/tasks/smoke-tests/rsc-kitchen-sink/tests/rsc-kitchen-sink.spec.ts b/tasks/smoke-tests/rsc-kitchen-sink/tests/rsc-kitchen-sink.spec.ts index 7f352cee1b7e..1fb026da3538 100644 --- a/tasks/smoke-tests/rsc-kitchen-sink/tests/rsc-kitchen-sink.spec.ts +++ b/tasks/smoke-tests/rsc-kitchen-sink/tests/rsc-kitchen-sink.spec.ts @@ -132,3 +132,13 @@ test('Server Cell - Error component', async ({ page }) => { await expect(page.getByText('UserExample not found')).toBeVisible() }) + +test('Server Cell in Layout', async ({ page }) => { + await page.goto('/') + + const mainText = await page.locator('.navigation-layout').innerText() + + // "The source of this server cell" should appear twice - once as a paragraph + // above the code block and then once more inside the codeblock itself + expect(mainText.match(/The source of this server cell/g)).toHaveLength(2) +})