From 4f4cf8410bbe14441de0ce5b96fa7002f2b65ff7 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 17 Nov 2023 22:20:36 +0100 Subject: [PATCH] Progressively enhanced form actions This enables forms to be submitted even before hydration. While handling an MPA-style POST request, we can parse the hidden form fields that contain meta data about the form action, using `decodeAction` and `decodeFormState`, to call the action, and send the result as form state, along with the rendered root, in the RSC response. The form state is used during server-side rendering (passed into `renderToReadableStream`) to render the result as part of the initial HTML, as well as in the client during hydration (passed into `hydrateRoot`) to avoid hydration mismatches. BREAKING CHANGE: The Webpack RSC server plugin now emits the server manifest in the structure that React expects. --- apps/cloudflare-app/src/client.tsx | 13 +--- apps/cloudflare-app/src/worker/index.tsx | 63 +++++++++++------- apps/cloudflare-app/webpack.config.js | 5 +- apps/vercel-app/src/client.tsx | 18 +----- .../src/edge-function-handler/index.tsx | 64 +++++++++++-------- apps/vercel-app/webpack.config.js | 5 +- .../src/client/create-fetch-element-stream.ts | 23 ------- packages/core/src/client/hydrate-app.tsx | 47 ++++++++++++++ packages/core/src/client/index.ts | 1 + .../core/src/client/router-location-utils.ts | 15 +++++ packages/core/src/client/router.tsx | 30 ++------- .../core/src/server/create-html-stream.tsx | 34 +++++++--- .../src/server/create-rsc-action-stream.ts | 22 ++----- .../core/src/server/create-rsc-app-stream.tsx | 17 ++++- .../core/src/server/create-rsc-form-state.ts | 27 ++++++++ packages/core/src/server/rsc.ts | 1 + packages/webpack-rsc/package.json | 2 +- .../src/webpack-rsc-client-plugin.ts | 8 +-- .../src/webpack-rsc-server-plugin.test.ts | 28 ++++++-- .../src/webpack-rsc-server-plugin.ts | 11 +++- types/react-dom-server.d.ts | 14 ++++ ...ct-dom.d.ts => react-dom-server.edge.d.ts} | 0 types/react-server-dom-webpack-server.d.ts | 19 ++++-- types/react-server-dom-webpack.d.ts | 15 +++-- 24 files changed, 307 insertions(+), 175 deletions(-) delete mode 100644 packages/core/src/client/create-fetch-element-stream.ts create mode 100644 packages/core/src/client/hydrate-app.tsx create mode 100644 packages/core/src/client/router-location-utils.ts create mode 100644 packages/core/src/server/create-rsc-form-state.ts create mode 100644 types/react-dom-server.d.ts rename types/{react-dom.d.ts => react-dom-server.edge.d.ts} (100%) diff --git a/apps/cloudflare-app/src/client.tsx b/apps/cloudflare-app/src/client.tsx index b6be410..e82437f 100644 --- a/apps/cloudflare-app/src/client.tsx +++ b/apps/cloudflare-app/src/client.tsx @@ -1,14 +1,5 @@ -import {Router} from '@mfng/core/client/browser'; -import * as React from 'react'; -import ReactDOMClient from 'react-dom/client'; +import {hydrateApp} from '@mfng/core/client'; // eslint-disable-next-line import/no-extraneous-dependencies import 'tailwindcss/tailwind.css'; -React.startTransition(() => { - ReactDOMClient.hydrateRoot( - document, - - - , - ); -}); +hydrateApp().catch(console.error); diff --git a/apps/cloudflare-app/src/worker/index.tsx b/apps/cloudflare-app/src/worker/index.tsx index 3aadacc..805877d 100644 --- a/apps/cloudflare-app/src/worker/index.tsx +++ b/apps/cloudflare-app/src/worker/index.tsx @@ -1,7 +1,12 @@ import {routerLocationAsyncLocalStorage} from '@mfng/core/router-location-async-local-storage'; -import {createRscActionStream, createRscAppStream} from '@mfng/core/server/rsc'; +import { + createRscActionStream, + createRscAppStream, + createRscFormState, +} from '@mfng/core/server/rsc'; import {createHtmlStream} from '@mfng/core/server/ssr'; import * as React from 'react'; +import type {ReactFormState} from 'react-dom/server'; import {App} from './app.js'; import { cssManifest, @@ -11,13 +16,17 @@ import { reactSsrManifest, } from './manifests.js'; -const handleGet: ExportedHandlerFetchHandler = async (request) => { +async function renderApp( + request: Request, + formState?: ReactFormState, +): Promise { const {pathname, search} = new URL(request.url); return routerLocationAsyncLocalStorage.run({pathname, search}, async () => { const rscAppStream = createRscAppStream(, { reactClientManifest, mainCssHref: cssManifest[`main.css`]!, + formState, }); if (request.headers.get(`accept`) === `text/x-component`) { @@ -35,37 +44,43 @@ const handleGet: ExportedHandlerFetchHandler = async (request) => { headers: {'Content-Type': `text/html; charset=utf-8`}, }); }); +} + +const handleGet: ExportedHandlerFetchHandler = async (request) => { + return renderApp(request); }; const handlePost: ExportedHandlerFetchHandler = async (request) => { const serverReferenceId = request.headers.get(`x-rsc-action`); - if (!serverReferenceId) { - console.error(`Missing server reference ("x-rsc-action" header).`); + if (serverReferenceId) { + // POST via callServer: - return new Response(null, {status: 400}); - } + const contentType = request.headers.get(`content-type`); - const body = await (request.headers - .get(`content-type`) - ?.startsWith(`multipart/form-data`) - ? request.formData() - : request.text()); - - const rscActionStream = await createRscActionStream({ - body, - serverReferenceId, - reactClientManifest, - reactServerManifest, - }); + const body = await (contentType?.startsWith(`multipart/form-data`) + ? request.formData() + : request.text()); - if (!rscActionStream) { - return new Response(null, {status: 500}); - } + const rscActionStream = await createRscActionStream({ + body, + serverReferenceId, + reactClientManifest, + reactServerManifest, + }); - return new Response(rscActionStream, { - headers: {'Content-Type': `text/x-component`}, - }); + return new Response(rscActionStream, { + status: rscActionStream ? 200 : 500, + headers: {'Content-Type': `text/x-component`}, + }); + } else { + // POST before hydration (progressive enhancement): + + const formData = await request.formData(); + const formState = await createRscFormState(formData, reactServerManifest); + + return renderApp(request, formState); + } }; const handler: ExportedHandler = { diff --git a/apps/cloudflare-app/webpack.config.js b/apps/cloudflare-app/webpack.config.js index 3608784..11e1d0f 100644 --- a/apps/cloudflare-app/webpack.config.js +++ b/apps/cloudflare-app/webpack.config.js @@ -100,6 +100,9 @@ export default function createConfigs(_env, argv) { path: path.join(process.cwd(), `dist`), libraryTarget: `module`, chunkFormat: `module`, + devtoolModuleFilenameTemplate: ( + /** @type {{ absoluteResourcePath: string; }} */ info, + ) => info.absoluteResourcePath, }, resolve: { plugins: [new ResolveTypeScriptPlugin()], @@ -113,7 +116,7 @@ export default function createConfigs(_env, argv) { module: { rules: [ { - resource: [/rsc\.ts$/, /app\.tsx$/], + resource: [/rsc\.ts$/, /\/app\.tsx$/], layer: webpackRscLayerName, }, { diff --git a/apps/vercel-app/src/client.tsx b/apps/vercel-app/src/client.tsx index 056ed6f..e82437f 100644 --- a/apps/vercel-app/src/client.tsx +++ b/apps/vercel-app/src/client.tsx @@ -1,19 +1,5 @@ -import {Router} from '@mfng/core/client/browser'; -import {Analytics} from '@vercel/analytics/react'; -import * as React from 'react'; -import ReactDOMClient from 'react-dom/client'; +import {hydrateApp} from '@mfng/core/client'; // eslint-disable-next-line import/no-extraneous-dependencies import 'tailwindcss/tailwind.css'; -import {reportWebVitals} from './vitals.js'; -React.startTransition(() => { - ReactDOMClient.hydrateRoot( - document, - - - - , - ); - - reportWebVitals(); -}); +hydrateApp().catch(console.error); diff --git a/apps/vercel-app/src/edge-function-handler/index.tsx b/apps/vercel-app/src/edge-function-handler/index.tsx index 7237f69..307546d 100644 --- a/apps/vercel-app/src/edge-function-handler/index.tsx +++ b/apps/vercel-app/src/edge-function-handler/index.tsx @@ -1,7 +1,12 @@ import {routerLocationAsyncLocalStorage} from '@mfng/core/router-location-async-local-storage'; -import {createRscActionStream, createRscAppStream} from '@mfng/core/server/rsc'; +import { + createRscActionStream, + createRscAppStream, + createRscFormState, +} from '@mfng/core/server/rsc'; import {createHtmlStream} from '@mfng/core/server/ssr'; import * as React from 'react'; +import type {ReactFormState} from 'react-dom/server'; import {App} from './app.js'; import { cssManifest, @@ -31,14 +36,17 @@ export default async function handler(request: Request): Promise { const oneDay = 60 * 60 * 24; -// eslint-disable-next-line @typescript-eslint/promise-function-async -function handleGet(request: Request): Promise { +async function renderApp( + request: Request, + formState?: ReactFormState, +): Promise { const {pathname, search} = new URL(request.url); return routerLocationAsyncLocalStorage.run({pathname, search}, async () => { const rscAppStream = createRscAppStream(, { reactClientManifest, mainCssHref: cssManifest[`main.css`]!, + formState, }); if (request.headers.get(`accept`) === `text/x-component`) { @@ -64,33 +72,39 @@ function handleGet(request: Request): Promise { }); } +async function handleGet(request: Request): Promise { + return renderApp(request); +} + async function handlePost(request: Request): Promise { const serverReferenceId = request.headers.get(`x-rsc-action`); - if (!serverReferenceId) { - console.error(`Missing server reference ("x-rsc-action" header).`); + if (serverReferenceId) { + // POST via callServer: - return new Response(null, {status: 400}); - } + const contentType = request.headers.get(`content-type`); - const body = await (request.headers - .get(`content-type`) - ?.startsWith(`multipart/form-data`) - ? request.formData() - : request.text()); - - const rscActionStream = await createRscActionStream({ - body, - serverReferenceId, - reactClientManifest, - reactServerManifest, - }); + const body = await (contentType?.startsWith(`multipart/form-data`) + ? request.formData() + : request.text()); - if (!rscActionStream) { - return new Response(null, {status: 500}); - } + const rscActionStream = await createRscActionStream({ + body, + serverReferenceId, + reactClientManifest, + reactServerManifest, + }); - return new Response(rscActionStream, { - headers: {'Content-Type': `text/x-component`}, - }); + return new Response(rscActionStream, { + status: rscActionStream ? 200 : 500, + headers: {'Content-Type': `text/x-component`}, + }); + } else { + // POST before hydration (progressive enhancement): + + const formData = await request.formData(); + const formState = await createRscFormState(formData, reactServerManifest); + + return renderApp(request, formState); + } } diff --git a/apps/vercel-app/webpack.config.js b/apps/vercel-app/webpack.config.js index dd6185a..228229e 100644 --- a/apps/vercel-app/webpack.config.js +++ b/apps/vercel-app/webpack.config.js @@ -127,6 +127,9 @@ export default function createConfigs(_env, argv) { path: outputFunctionDirname, libraryTarget: `module`, chunkFormat: `module`, + devtoolModuleFilenameTemplate: ( + /** @type {{ absoluteResourcePath: string; }} */ info, + ) => info.absoluteResourcePath, }, resolve: { plugins: [new ResolveTypeScriptPlugin()], @@ -140,7 +143,7 @@ export default function createConfigs(_env, argv) { module: { rules: [ { - resource: [/rsc\.ts$/, /app\.tsx$/], + resource: [/rsc\.ts$/, /\/app\.tsx$/], layer: webpackRscLayerName, }, { diff --git a/packages/core/src/client/create-fetch-element-stream.ts b/packages/core/src/client/create-fetch-element-stream.ts deleted file mode 100644 index 7610ad9..0000000 --- a/packages/core/src/client/create-fetch-element-stream.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react'; -import ReactServerDOMClient from 'react-server-dom-webpack/client.browser'; -import {callServer} from './call-server.js'; - -export function createFetchElementStream( - initialUrlPath: string, -): (urlPath: string) => React.Thenable { - return React.cache(function fetchElementStream( - urlPath: string, - ): React.Thenable { - if (urlPath === initialUrlPath) { - return ReactServerDOMClient.createFromReadableStream( - self.initialRscResponseStream, - {callServer}, - ); - } - - return ReactServerDOMClient.createFromFetch( - fetch(urlPath, {headers: {accept: `text/x-component`}}), - {callServer}, - ); - }); -} diff --git a/packages/core/src/client/hydrate-app.tsx b/packages/core/src/client/hydrate-app.tsx new file mode 100644 index 0000000..d18a338 --- /dev/null +++ b/packages/core/src/client/hydrate-app.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import type {ReactFormState} from 'react-dom/client'; +import ReactDOMClient from 'react-dom/client'; +import ReactServerDOMClient from 'react-server-dom-webpack/client.browser'; +import {callServer} from './call-server.js'; +import {createUrlPath} from './router-location-utils.js'; +import {Router} from './router.js'; + +export interface RscAppResult { + readonly root: React.ReactElement; + readonly formState?: ReactFormState; +} + +export async function hydrateApp(): Promise { + const {root: initialRoot, formState} = + await ReactServerDOMClient.createFromReadableStream( + self.initialRscResponseStream, + {callServer}, + ); + + const initialUrlPath = createUrlPath(document.location); + + const fetchRoot = React.cache(async function fetchRoot( + urlPath: string, + ): Promise { + if (urlPath === initialUrlPath) { + return initialRoot; + } + + const {root} = await ReactServerDOMClient.createFromFetch( + fetch(urlPath, {headers: {accept: `text/x-component`}}), + {callServer}, + ); + + return root; + }); + + React.startTransition(() => { + ReactDOMClient.hydrateRoot( + document, + + + , + {formState}, + ); + }); +} diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index c6f98c8..fea9781 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1 +1,2 @@ +export * from './hydrate-app.js'; export * from './use-router.js'; diff --git a/packages/core/src/client/router-location-utils.ts b/packages/core/src/client/router-location-utils.ts new file mode 100644 index 0000000..74f7e59 --- /dev/null +++ b/packages/core/src/client/router-location-utils.ts @@ -0,0 +1,15 @@ +import type {RouterLocation} from '../use-router-location.js'; + +export function createUrlPath(location: RouterLocation): string { + const {pathname, search} = location; + + return `${pathname}${normalizeSearch(search)}`; +} + +export function createUrl(location: RouterLocation): URL { + return new URL(createUrlPath(location), document.location.origin); +} + +function normalizeSearch(search: string): string { + return `${search.replace(/(^[^?].*)/, `?$1`)}`; +} diff --git a/packages/core/src/client/router.tsx b/packages/core/src/client/router.tsx index 6cf75f6..4a6ca50 100644 --- a/packages/core/src/client/router.tsx +++ b/packages/core/src/client/router.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import type {RouterLocation} from '../use-router-location.js'; -import {createFetchElementStream} from './create-fetch-element-stream.js'; +import {createUrl, createUrlPath} from './router-location-utils.js'; import {RouterLocationContext} from './use-router-location.js'; import {RouterContext} from './use-router.js'; -const fetchElementStream = createFetchElementStream( - createUrlPath(document.location), -); +export interface RouterProps { + readonly fetchRoot: (urlPath: string) => Promise; +} interface RouterState { readonly location: RouterLocation; @@ -15,7 +15,7 @@ interface RouterState { type RouterAction = 'push' | 'replace' | 'pop'; -export function Router(): JSX.Element { +export function Router({fetchRoot}: RouterProps): React.ReactNode { const [routerState, setRouterState] = React.useState(() => { const {pathname, search} = document.location; @@ -77,29 +77,13 @@ export function Router(): JSX.Element { } }, [routerState]); - const elementStreamPromise = fetchElementStream( - createUrlPath(routerState.location), - ); + const rootPromise = fetchRoot(createUrlPath(routerState.location)); return ( - {React.use(elementStreamPromise)} + {React.use(rootPromise)} ); } - -function createUrlPath(location: RouterLocation): string { - const {pathname, search} = location; - - return `${pathname}${normalizeSearch(search)}`; -} - -function createUrl(location: RouterLocation): URL { - return new URL(createUrlPath(location), document.location.origin); -} - -function normalizeSearch(search: string): string { - return `${search.replace(/(^[^?].*)/, `?$1`)}`; -} diff --git a/packages/core/src/server/create-html-stream.tsx b/packages/core/src/server/create-html-stream.tsx index df87b57..29fbb68 100644 --- a/packages/core/src/server/create-html-stream.tsx +++ b/packages/core/src/server/create-html-stream.tsx @@ -2,11 +2,13 @@ import './node-compat-environment.js'; import * as React from 'react'; +import type {ReactFormState} from 'react-dom/server'; import ReactDOMServer from 'react-dom/server.edge'; import type {SSRManifest} from 'react-server-dom-webpack'; import ReactServerDOMClient from 'react-server-dom-webpack/client.edge'; import {createBufferedTransformStream} from './create-buffered-transform-stream.js'; import {createInitialRscResponseTransformStream} from './create-initial-rsc-response-transform-stream.js'; +import type {RscAppResult} from './create-rsc-app-stream.js'; export interface CreateHtmlStreamOptions { readonly reactSsrManifest: SSRManifest; @@ -22,17 +24,32 @@ export async function createHtmlStream( const {reactSsrManifest, bootstrapScripts} = options; const [rscStream1, rscStream2] = rscStream.tee(); - let root: React.Thenable; + let cachedRoot: Promise | undefined; - const ServerRoot = (): JSX.Element => { - // This needs to be created during render, otherwise there will be no + // @ts-expect-error This should be a tuple or undefined, but we need to assign + // it inside of getRootAndAssignFormState, which happens after it is already + // passed into ReactDOMServer.renderToReadableStream, unfortunately. + // Hopefully, React will improve the ergonomics of this in the future. + const lazyFormState: ReactFormState = []; + + const getRootAndAssignFormState = async () => { + const {root, formState} = + await ReactServerDOMClient.createFromReadableStream( + rscStream1, + {ssrManifest: reactSsrManifest}, + ); + + Object.assign(lazyFormState, formState); + + return root; + }; + + const ServerRoot = (): React.ReactNode => { + // The root needs to be created during render, otherwise there will be no // current request defined that the chunk preloads can be attached to. - root ??= ReactServerDOMClient.createFromReadableStream( - rscStream1, - {ssrManifest: reactSsrManifest}, - ); + cachedRoot ??= getRootAndAssignFormState(); - return React.use(root); + return React.use(cachedRoot); }; const htmlStream = await ReactDOMServer.renderToReadableStream( @@ -40,6 +57,7 @@ export async function createHtmlStream( { bootstrapScriptContent: rscResponseStreamBootstrapScriptContent, bootstrapScripts, + formState: lazyFormState, }, ); diff --git a/packages/core/src/server/create-rsc-action-stream.ts b/packages/core/src/server/create-rsc-action-stream.ts index 1607be8..33e1cb3 100644 --- a/packages/core/src/server/create-rsc-action-stream.ts +++ b/packages/core/src/server/create-rsc-action-stream.ts @@ -1,4 +1,4 @@ -import type {ClientManifest} from 'react-server-dom-webpack'; +import type {ClientManifest, ServerManifest} from 'react-server-dom-webpack'; import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; export interface CreateRscActionStreamOptions { @@ -11,9 +11,9 @@ export interface CreateRscActionStreamOptions { readonly reactServerManifest: ServerManifest; } -export type ServerManifest = Record; - -declare var __webpack_require__: (moduleId: string) => Record; +declare var __webpack_require__: ( + moduleId: string | number, +) => Record; export async function createRscActionStream( options: CreateRscActionStreamOptions, @@ -21,17 +21,9 @@ export async function createRscActionStream( const {body, serverReferenceId, reactClientManifest, reactServerManifest} = options; - const [moduleId, exportName] = serverReferenceId?.split(`#`) ?? []; - - if (!moduleId || !exportName) { - console.error( - `Invalid server reference ID: ${JSON.stringify(serverReferenceId)}`, - ); - - return undefined; - } + const serverReference = reactServerManifest[serverReferenceId]; - if (!reactServerManifest[moduleId]?.includes(exportName)) { + if (!serverReference) { console.error( `Unknown server reference ID: ${JSON.stringify(serverReferenceId)}`, ); @@ -44,7 +36,7 @@ export async function createRscActionStream( return undefined; } - const action = __webpack_require__(moduleId)[exportName]; + const action = __webpack_require__(serverReference.id)[serverReference.name]; if (typeof action !== `function`) { console.error( diff --git a/packages/core/src/server/create-rsc-app-stream.tsx b/packages/core/src/server/create-rsc-app-stream.tsx index 153f267..e038498 100644 --- a/packages/core/src/server/create-rsc-app-stream.tsx +++ b/packages/core/src/server/create-rsc-app-stream.tsx @@ -1,19 +1,26 @@ import * as React from 'react'; +import type {ReactFormState} from 'react-dom/server'; import type {ClientManifest} from 'react-server-dom-webpack'; import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; export interface CreateRscAppStreamOptions { readonly reactClientManifest: ClientManifest; readonly mainCssHref?: string; + readonly formState?: ReactFormState; +} + +export interface RscAppResult { + readonly root: React.ReactElement; + readonly formState?: ReactFormState; } export function createRscAppStream( app: React.ReactNode, options: CreateRscAppStreamOptions, ): ReadableStream { - const {reactClientManifest, mainCssHref} = options; + const {reactClientManifest, mainCssHref, formState} = options; - return ReactServerDOMServer.renderToReadableStream( + const root = ( <> {mainCssHref && ( )} {app} - , + + ); + + return ReactServerDOMServer.renderToReadableStream( + {root, formState: formState as (string | number)[]}, reactClientManifest, ); } diff --git a/packages/core/src/server/create-rsc-form-state.ts b/packages/core/src/server/create-rsc-form-state.ts new file mode 100644 index 0000000..7037c47 --- /dev/null +++ b/packages/core/src/server/create-rsc-form-state.ts @@ -0,0 +1,27 @@ +import type {ReactFormState} from 'react-dom/server'; +import type {ServerManifest} from 'react-server-dom-webpack'; +import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; + +export async function createRscFormState( + formData: FormData, + reactServerManifest: ServerManifest, +): Promise { + const action = await ReactServerDOMServer.decodeAction( + formData, + reactServerManifest, + ); + + if (!action) { + return undefined; + } + + const result = await action(); + + const formState = await ReactServerDOMServer.decodeFormState( + result, + formData, + reactServerManifest, + ); + + return formState ?? undefined; +} diff --git a/packages/core/src/server/rsc.ts b/packages/core/src/server/rsc.ts index cf0ff56..f7ca34c 100644 --- a/packages/core/src/server/rsc.ts +++ b/packages/core/src/server/rsc.ts @@ -1,2 +1,3 @@ export * from './create-rsc-action-stream.js'; export * from './create-rsc-app-stream.js'; +export * from './create-rsc-form-state.js'; diff --git a/packages/webpack-rsc/package.json b/packages/webpack-rsc/package.json index 8c80ba0..d7bb9c4 100644 --- a/packages/webpack-rsc/package.json +++ b/packages/webpack-rsc/package.json @@ -1,6 +1,6 @@ { "name": "@mfng/webpack-rsc", - "version": "2.5.0", + "version": "3.0.0", "description": "A set of Webpack loaders and plugins for React Server Components", "repository": { "type": "git", diff --git a/packages/webpack-rsc/src/webpack-rsc-client-plugin.ts b/packages/webpack-rsc/src/webpack-rsc-client-plugin.ts index cb890fb..5e3d00e 100644 --- a/packages/webpack-rsc/src/webpack-rsc-client-plugin.ts +++ b/packages/webpack-rsc/src/webpack-rsc-client-plugin.ts @@ -1,7 +1,7 @@ import {createRequire} from 'module'; import type { ClientManifest, - ClientReferenceMetadata, + ImportManifestEntry, SSRManifest, } from 'react-server-dom-webpack'; import type Webpack from 'webpack'; @@ -136,10 +136,8 @@ export class WebpackRscClientPlugin { if (module) { const moduleId = compilation.chunkGraph.getModuleId(module); - const ssrModuleMetaData: Record< - string, - ClientReferenceMetadata - > = {}; + const ssrModuleMetaData: Record = + {}; for (const {id, exportName, ssrId} of clientReferences) { // Theoretically the used client and SSR export names should diff --git a/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts b/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts index 65f1c75..a125b8c 100644 --- a/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts +++ b/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts @@ -200,10 +200,18 @@ async function serverFunctionPassedFromServer() { ); expect(JSON.parse(manifestFile)).toEqual({ - '(react-server)/./src/__fixtures__/server-function-imported-from-client.js': - [`serverFunctionImportedFromClient`], - '(react-server)/./src/__fixtures__/server-function-passed-from-server.js': - [`serverFunctionPassedFromServer`], + '(react-server)/./src/__fixtures__/server-function-imported-from-client.js#serverFunctionImportedFromClient': + { + chunks: [], + id: `(react-server)/./src/__fixtures__/server-function-imported-from-client.js`, + name: `serverFunctionImportedFromClient`, + }, + '(react-server)/./src/__fixtures__/server-function-passed-from-server.js#serverFunctionPassedFromServer': + { + chunks: [], + id: `(react-server)/./src/__fixtures__/server-function-passed-from-server.js`, + name: `serverFunctionPassedFromServer`, + }, }); }); @@ -286,8 +294,16 @@ async function serverFunctionPassedFromServer() { ); expect(JSON.parse(manifestFile)).toEqual({ - 839: [`serverFunctionImportedFromClient`], - 871: [`serverFunctionPassedFromServer`], + '839#serverFunctionImportedFromClient': { + chunks: [], + id: 839, + name: `serverFunctionImportedFromClient`, + }, + '871#serverFunctionPassedFromServer': { + chunks: [], + id: 871, + name: `serverFunctionPassedFromServer`, + }, }); }); diff --git a/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts b/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts index 764db64..5d7bebe 100644 --- a/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts +++ b/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts @@ -1,4 +1,5 @@ import type {Directive, ModuleDeclaration, Statement} from 'estree'; +import type {ServerManifest} from 'react-server-dom-webpack'; import type Webpack from 'webpack'; import type {ServerReferencesMap} from './webpack-rsc-client-loader.cjs'; import type {ClientReferencesMap} from './webpack-rsc-server-loader.cjs'; @@ -19,7 +20,7 @@ export const webpackRscLayerName = `react-server`; export class WebpackRscServerPlugin { private clientReferencesMap: ClientReferencesMap; private serverReferencesMap: ServerReferencesMap | undefined; - private serverManifest: Record = {}; + private serverManifest: ServerManifest = {}; private serverManifestFilename: string; private clientModuleResources = new Set(); private serverModuleResources = new Set(); @@ -217,7 +218,13 @@ export class WebpackRscServerPlugin { exportNames, }); - this.serverManifest[moduleId] = exportNames; + for (const exportName of exportNames) { + this.serverManifest[`${moduleId}#${exportName}`] = { + id: moduleId, + chunks: [], + name: exportName, + }; + } } } }, diff --git a/types/react-dom-server.d.ts b/types/react-dom-server.d.ts new file mode 100644 index 0000000..d4d4ec4 --- /dev/null +++ b/types/react-dom-server.d.ts @@ -0,0 +1,14 @@ +import {RenderToReadableStreamOptions} from 'react-dom/server'; + +declare module 'react-dom/server' { + export type ReactFormState = [ + unknown /* actual state value */, + string /* key path */, + string /* Server Reference ID */, + number /* number of bound arguments */, + ]; + + export interface RenderToReadableStreamOptions { + formState?: ReactFormState | null; + } +} diff --git a/types/react-dom.d.ts b/types/react-dom-server.edge.d.ts similarity index 100% rename from types/react-dom.d.ts rename to types/react-dom-server.edge.d.ts diff --git a/types/react-server-dom-webpack-server.d.ts b/types/react-server-dom-webpack-server.d.ts index 099d6e4..3621df0 100644 --- a/types/react-server-dom-webpack-server.d.ts +++ b/types/react-server-dom-webpack-server.d.ts @@ -1,17 +1,13 @@ declare module 'react-server-dom-webpack/server.edge' { import type {ReactElement, Thenable} from 'react'; + import type {ReactFormState} from 'react-dom/server'; import type { ClientManifest, ReactClientValue, ReactServerValue, + ServerManifest, } from 'react-server-dom-webpack'; - export type LazyComponent = { - $$typeof: symbol | number; - _payload: P; - _init: (payload: P) => T; - }; - export interface RenderToReadableStreamOptions { identifierPrefix?: string; signal?: AbortSignal; @@ -34,4 +30,15 @@ declare module 'react-server-dom-webpack/server.edge' { ): ReadableStream; export function decodeReply(body: string | FormData): Thenable; + + export function decodeAction( + body: FormData, + serverManifest: ServerManifest, + ): Promise<() => unknown> | null; + + export function decodeFormState( + actionResult: unknown, + body: FormData, + serverManifest: ServerManifest, + ): Promise; } diff --git a/types/react-server-dom-webpack.d.ts b/types/react-server-dom-webpack.d.ts index cb50fcc..8514786 100644 --- a/types/react-server-dom-webpack.d.ts +++ b/types/react-server-dom-webpack.d.ts @@ -7,7 +7,11 @@ declare module 'react-server-dom-webpack' { } from 'react'; export interface ClientManifest { - [id: string]: ClientReferenceMetadata; + [id: string]: ImportManifestEntry; + } + + export interface ServerManifest { + [id: string]: ImportManifestEntry; } export interface SSRManifest { @@ -17,7 +21,7 @@ declare module 'react-server-dom-webpack' { export interface SSRModuleMap { [clientId: string]: { - [clientExportName: string]: ClientReferenceMetadata; + [clientExportName: string]: ImportManifestEntry; }; } @@ -26,8 +30,9 @@ declare module 'react-server-dom-webpack' { crossOrigin?: 'use-credentials' | ''; } - export interface ClientReferenceMetadata { + export interface ImportManifestEntry { id: string | number; + // chunks is a double indexed array of chunkId / chunkFilename pairs chunks: (string | number)[]; name: string; } @@ -44,12 +49,12 @@ declare module 'react-server-dom-webpack' { | ReactElement // | LazyExoticComponent // TODO: this is invalid and widens the type to any // References are passed by their value - | ClientReferenceMetadata + | ImportManifestEntry | ServerReference // The rest are passed as is. Sub-types can be passed in but lose their // subtype, so the receiver can only accept once of these. | ReactElement - | ReactElement + | ReactElement | Context // ServerContext | string | boolean