Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useFormState: Reuse state from previous form submission #27321

Merged
merged 2 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion fixtures/flight/server/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,15 @@ app.all('/', async function (req, res, next) {
// For HTML, we're a "client" emulator that runs the client code,
// so we start by consuming the RSC payload. This needs a module
// map that reverse engineers the client-side path to the SSR path.
const root = await createFromNodeStream(rscResponse, moduleMap);
const {root, formState} = await createFromNodeStream(
rscResponse,
moduleMap
);
// Render it into HTML by resolving the client components
res.set('Content-type', 'text/html');
const {pipe} = renderToPipeableStream(root, {
bootstrapScripts: mainJSChunks,
experimental_formState: formState,
});
pipe(res);
} catch (e) {
Expand Down
15 changes: 9 additions & 6 deletions fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const {readFile} = require('fs').promises;

const React = require('react');

async function renderApp(res, returnValue) {
async function renderApp(res, returnValue, formState) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
Expand Down Expand Up @@ -93,13 +93,13 @@ async function renderApp(res, returnValue) {
React.createElement(App),
];
// For client-invoked server actions we refresh the tree and return a return value.
const payload = returnValue ? {returnValue, root} : root;
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap);
pipe(res);
}

app.get('/', async function (req, res) {
await renderApp(res, null);
await renderApp(res, null, null);
});

app.post('/', bodyParser.text(), async function (req, res) {
Expand All @@ -108,6 +108,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
decodeReply,
decodeReplyFromBusboy,
decodeAction,
decodeFormState,
} = await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
if (serverReference) {
Expand Down Expand Up @@ -139,7 +140,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
// We handle the error on the client
}
// Refresh the client and return the value
renderApp(res, result);
renderApp(res, result, null);
} else {
// This is the progressive enhancement case
const UndiciRequest = require('undici').Request;
Expand All @@ -153,12 +154,14 @@ app.post('/', bodyParser.text(), async function (req, res) {
const action = await decodeAction(formData);
try {
// Wait for any mutations
await action();
const result = await action();
const formState = decodeFormState(result, formData);
renderApp(res, null, formState);
} catch (x) {
const {setServerState} = await import('../src/ServerState.js');
setServerState('Error: ' + x.message);
renderApp(res, null, null);
}
renderApp(res, null);
}
});

Expand Down
8 changes: 4 additions & 4 deletions fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {Client} from './Client.js';

import {Note} from './cjs/Note.js';

import {like, greet} from './actions.js';
import {like, greet, increment} from './actions.js';

import {getServerState} from './ServerState.js';

Expand All @@ -32,9 +32,9 @@ export default async function App() {
<body>
<Container>
<h1>{getServerState()}</h1>
<Counter />
<Counter2 />
<Counter3 />
<Counter incrementAction={increment} />
<Counter2 incrementAction={increment} />
<Counter3 incrementAction={increment} />
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
Expand Down
9 changes: 6 additions & 3 deletions fixtures/flight/src/Counter.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
'use client';

import * as React from 'react';
import {experimental_useFormState as useFormState} from 'react-dom';

import Container from './Container.js';

export function Counter() {
const [count, setCount] = React.useState(0);
export function Counter({incrementAction}) {
const [count, incrementFormAction] = useFormState(incrementAction, 0);
return (
<Container>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<form>
<button formAction={incrementFormAction}>Count: {count}</button>
</form>
</Container>
);
}
4 changes: 4 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ export async function greet(formData) {
}
return 'Hi ' + name + '!';
}

export async function increment(n) {
return n + 1;
}
38 changes: 25 additions & 13 deletions fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,33 @@ async function callServer(id, args) {
return returnValue;
}

let data = createFromFetch(
fetch('/', {
headers: {
Accept: 'text/x-component',
},
}),
{
callServer,
}
);

function Shell({data}) {
const [root, setRoot] = useState(use(data));
const [root, setRoot] = useState(data);
updateRoot = setRoot;
return root;
}

ReactDOM.hydrateRoot(document, <Shell data={data} />);
async function hydrateApp() {
const {root, returnValue, formState} = await createFromFetch(
fetch('/', {
headers: {
Accept: 'text/x-component',
},
}),
{
callServer,
}
);

ReactDOM.hydrateRoot(document, <Shell data={root} />, {
// TODO: This part doesn't actually work because the server only returns
// form state during the request that submitted the form. Which means it
// the state needs to be transported as part of the HTML stream. We intend
// to add a feature to Fizz for this, but for now it's up to the
// metaframework to implement correctly.
experimental_formState: formState,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So since we're serializing formState (nor the rest of the RSC payload) submitting this without JS and then hydrating the result will result in a hydration mismatch in the fixture, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes. Didn't catch that because I disabled hydration entirely when I was testing. Should add a button or something to start hydration asynchronously.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not actually sure how to transport this since it's a separate process. Seems like it'd have to be encoded into the HTML.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, Next.js encodes the stream as text into the html (which doesn't allow for binary). We didn't bother doing that here because we wanted a more built-in mechanism for this upstream anyway. Seems fine to leave with a comment for now.

});
}

// Remove this line to simulate MPA behavior
hydrateApp();
11 changes: 6 additions & 5 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* @flow
*/

import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
import type {
Thenable,
FulfilledThenable,
RejectedThenable,
ReactCustomFormAction,
} from 'shared/ReactTypes';

import {
REACT_ELEMENT_TYPE,
Expand All @@ -23,10 +28,6 @@ import {
} from 'shared/ReactSerializationErrors';

import isArray from 'shared/isArray';
import type {
FulfilledThenable,
RejectedThenable,
} from '../../shared/ReactTypes';

import {usedWithSSR} from './ReactFlightClientConfig';

Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/src/client/ReactDOMLegacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
noopOnRecoverableError,
// TODO(luna) Support hydration later
null,
null,
);
container._reactRootContainer = root;
markContainerAsRoot(root.current, container);
Expand Down
12 changes: 11 additions & 1 deletion packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {
FiberRoot,
TransitionTracingCallbacks,
Expand All @@ -21,6 +21,8 @@ import {
enableHostSingletons,
allowConcurrentByDefault,
disableCommentsAsDOMContainers,
enableAsyncActions,
enableFormActions,
} from 'shared/ReactFeatureFlags';

import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
Expand Down Expand Up @@ -55,6 +57,7 @@ export type HydrateRootOptions = {
unstable_transitionCallbacks?: TransitionTracingCallbacks,
identifierPrefix?: string,
onRecoverableError?: (error: mixed) => void,
experimental_formState?: ReactFormState<any> | null,
...
};

Expand Down Expand Up @@ -302,6 +305,7 @@ export function hydrateRoot(
let identifierPrefix = '';
let onRecoverableError = defaultOnRecoverableError;
let transitionCallbacks = null;
let formState = null;
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
Expand All @@ -321,6 +325,11 @@ export function hydrateRoot(
if (options.unstable_transitionCallbacks !== undefined) {
transitionCallbacks = options.unstable_transitionCallbacks;
}
if (enableAsyncActions && enableFormActions) {
if (options.experimental_formState !== undefined) {
formState = options.experimental_formState;
}
}
}

const root = createHydrationContainer(
Expand All @@ -334,6 +343,7 @@ export function hydrateRoot(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
formState,
);
markContainerAsRoot(root.current, container);
Dispatcher.current = ReactDOMClientDispatcher;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

Expand Down Expand Up @@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -117,6 +118,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

Expand Down Expand Up @@ -39,6 +39,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -108,6 +109,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

Expand Down Expand Up @@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -117,6 +118,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {Writable} from 'stream';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
Expand Down Expand Up @@ -54,6 +54,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -97,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
options ? options.onShellError : undefined,
undefined,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
}

Expand Down
Loading