Skip to content

Commit

Permalink
Progressively enhanced form actions
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
unstubbable committed Nov 17, 2023
1 parent 46b3ba8 commit 4f4cf84
Show file tree
Hide file tree
Showing 24 changed files with 307 additions and 175 deletions.
13 changes: 2 additions & 11 deletions apps/cloudflare-app/src/client.tsx
Original file line number Diff line number Diff line change
@@ -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,
<React.StrictMode>
<Router />
</React.StrictMode>,
);
});
hydrateApp().catch(console.error);
63 changes: 39 additions & 24 deletions apps/cloudflare-app/src/worker/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,13 +16,17 @@ import {
reactSsrManifest,
} from './manifests.js';

const handleGet: ExportedHandlerFetchHandler = async (request) => {
async function renderApp(
request: Request,
formState?: ReactFormState,
): Promise<Response> {
const {pathname, search} = new URL(request.url);

return routerLocationAsyncLocalStorage.run({pathname, search}, async () => {
const rscAppStream = createRscAppStream(<App />, {
reactClientManifest,
mainCssHref: cssManifest[`main.css`]!,
formState,
});

if (request.headers.get(`accept`) === `text/x-component`) {
Expand All @@ -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 = {
Expand Down
5 changes: 4 additions & 1 deletion apps/cloudflare-app/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -113,7 +116,7 @@ export default function createConfigs(_env, argv) {
module: {
rules: [
{
resource: [/rsc\.ts$/, /app\.tsx$/],
resource: [/rsc\.ts$/, /\/app\.tsx$/],
layer: webpackRscLayerName,
},
{
Expand Down
18 changes: 2 additions & 16 deletions apps/vercel-app/src/client.tsx
Original file line number Diff line number Diff line change
@@ -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,
<React.StrictMode>
<Router />
<Analytics />
</React.StrictMode>,
);

reportWebVitals();
});
hydrateApp().catch(console.error);
64 changes: 39 additions & 25 deletions apps/vercel-app/src/edge-function-handler/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,14 +36,17 @@ export default async function handler(request: Request): Promise<Response> {

const oneDay = 60 * 60 * 24;

// eslint-disable-next-line @typescript-eslint/promise-function-async
function handleGet(request: Request): Promise<Response> {
async function renderApp(
request: Request,
formState?: ReactFormState,
): Promise<Response> {
const {pathname, search} = new URL(request.url);

return routerLocationAsyncLocalStorage.run({pathname, search}, async () => {
const rscAppStream = createRscAppStream(<App />, {
reactClientManifest,
mainCssHref: cssManifest[`main.css`]!,
formState,
});

if (request.headers.get(`accept`) === `text/x-component`) {
Expand All @@ -64,33 +72,39 @@ function handleGet(request: Request): Promise<Response> {
});
}

async function handleGet(request: Request): Promise<Response> {
return renderApp(request);
}

async function handlePost(request: Request): Promise<Response> {
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);
}
}
5 changes: 4 additions & 1 deletion apps/vercel-app/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -140,7 +143,7 @@ export default function createConfigs(_env, argv) {
module: {
rules: [
{
resource: [/rsc\.ts$/, /app\.tsx$/],
resource: [/rsc\.ts$/, /\/app\.tsx$/],
layer: webpackRscLayerName,
},
{
Expand Down
23 changes: 0 additions & 23 deletions packages/core/src/client/create-fetch-element-stream.ts

This file was deleted.

47 changes: 47 additions & 0 deletions packages/core/src/client/hydrate-app.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
const {root: initialRoot, formState} =
await ReactServerDOMClient.createFromReadableStream<RscAppResult>(
self.initialRscResponseStream,
{callServer},
);

const initialUrlPath = createUrlPath(document.location);

const fetchRoot = React.cache(async function fetchRoot(
urlPath: string,
): Promise<React.ReactElement> {
if (urlPath === initialUrlPath) {
return initialRoot;
}

const {root} = await ReactServerDOMClient.createFromFetch<RscAppResult>(
fetch(urlPath, {headers: {accept: `text/x-component`}}),
{callServer},
);

return root;
});

React.startTransition(() => {
ReactDOMClient.hydrateRoot(
document,
<React.StrictMode>
<Router fetchRoot={fetchRoot} />
</React.StrictMode>,
{formState},
);
});
}
1 change: 1 addition & 0 deletions packages/core/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './hydrate-app.js';
export * from './use-router.js';
15 changes: 15 additions & 0 deletions packages/core/src/client/router-location-utils.ts
Original file line number Diff line number Diff line change
@@ -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`)}`;
}
Loading

0 comments on commit 4f4cf84

Please sign in to comment.