Skip to content

Commit 51587ba

Browse files
committed
useFormState's permalink option changes form target
When the `permalink` option is passed to `useFormState`, and the form is submitted before it has hydrated, the permalink will be used as the target of the form action, enabling MPA-style form submissions. (Note that submitting a form without hydration is a feature of Server Actions; it doesn't work with regular client actions.) It does not have any effect after the form has hydrated.
1 parent 9a01f8d commit 51587ba

File tree

2 files changed

+110
-5
lines changed

2 files changed

+110
-5
lines changed

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let React;
3030
let ReactDOMServer;
3131
let ReactServerDOMServer;
3232
let ReactServerDOMClient;
33+
let useFormState;
3334

3435
describe('ReactFlightDOMForm', () => {
3536
beforeEach(() => {
@@ -47,6 +48,7 @@ describe('ReactFlightDOMForm', () => {
4748
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
4849
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
4950
ReactDOMServer = require('react-dom/server.edge');
51+
useFormState = require('react-dom').experimental_useFormState;
5052
container = document.createElement('div');
5153
document.body.appendChild(container);
5254
});
@@ -308,4 +310,82 @@ describe('ReactFlightDOMForm', () => {
308310
expect(result).toBe('hello');
309311
expect(foo).toBe('barobject');
310312
});
313+
314+
// @gate enableFormActions
315+
// @gate enableAsyncActions
316+
it("useFormState's dispatch binds the initial state to the provided action", async () => {
317+
let serverActionResult = null;
318+
319+
const serverAction = serverExports(function action(prevState, formData) {
320+
const newState = {
321+
count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
322+
};
323+
serverActionResult = newState;
324+
return newState;
325+
});
326+
327+
const initialState = {count: 1};
328+
function Client({action}) {
329+
const [state, dispatch] = useFormState(action, initialState);
330+
return (
331+
<form action={dispatch}>
332+
<span>Count: {state.count}</span>
333+
<input type="text" name="incrementAmount" defaultValue="5" />
334+
</form>
335+
);
336+
}
337+
const ClientRef = await clientExports(Client);
338+
339+
const rscStream = ReactServerDOMServer.renderToReadableStream(
340+
<ClientRef action={serverAction} />,
341+
webpackMap,
342+
);
343+
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
344+
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
345+
await readIntoContainer(ssrStream);
346+
347+
const form = container.firstChild;
348+
const span = container.getElementsByTagName('span')[0];
349+
expect(span.textContent).toBe('Count: 1');
350+
351+
await submit(form);
352+
expect(serverActionResult.count).toBe(6);
353+
});
354+
355+
// @gate enableFormActions
356+
// @gate enableAsyncActions
357+
it("useFormState can change the action's target with the `permalink` argument", async () => {
358+
const serverAction = serverExports(function action(prevState) {
359+
return {state: prevState.count + 1};
360+
});
361+
362+
const initialState = {count: 1};
363+
function Client({action}) {
364+
const [state, dispatch] = useFormState(
365+
action,
366+
initialState,
367+
'/permalink',
368+
);
369+
return (
370+
<form action={dispatch}>
371+
<span>Count: {state.count}</span>
372+
</form>
373+
);
374+
}
375+
const ClientRef = await clientExports(Client);
376+
377+
const rscStream = ReactServerDOMServer.renderToReadableStream(
378+
<ClientRef action={serverAction} />,
379+
webpackMap,
380+
);
381+
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
382+
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
383+
await readIntoContainer(ssrStream);
384+
385+
const form = container.firstChild;
386+
const span = container.getElementsByTagName('span')[0];
387+
expect(span.textContent).toBe('Count: 1');
388+
389+
expect(form.target).toBe('/permalink');
390+
});
311391
});

packages/react-server/src/ReactFizzHooks.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
StartTransitionOptions,
1515
Thenable,
1616
Usable,
17+
ReactCustomFormAction,
1718
} from 'shared/ReactTypes';
1819

1920
import type {ResumableState} from './ReactFizzConfig';
@@ -542,10 +543,6 @@ function unsupportedSetOptimisticState() {
542543
throw new Error('Cannot update optimistic state while rendering.');
543544
}
544545

545-
function unsupportedDispatchFormState() {
546-
throw new Error('Cannot update form state while rendering.');
547-
}
548-
549546
function useOptimistic<S, A>(
550547
passthrough: S,
551548
reducer: ?(S, A) => S,
@@ -560,7 +557,35 @@ function useFormState<S, P>(
560557
permalink?: string,
561558
): [S, (P) => void] {
562559
resolveCurrentlyRenderingComponent();
563-
return [initialState, unsupportedDispatchFormState];
560+
561+
// Bind the initial state to the first argument of the action.
562+
// TODO: Use the keypath (or permalink) to check if there's matching state
563+
// from the previous page.
564+
const boundAction = action.bind(null, initialState);
565+
566+
// Wrap the action so the return value is void.
567+
const dispatch = (payload: P): void => {
568+
boundAction(payload);
569+
};
570+
571+
// $FlowIgnore[prop-missing]
572+
if (typeof boundAction.$$FORM_ACTION === 'function') {
573+
// $FlowIgnore[prop-missing]
574+
dispatch.$$FORM_ACTION = (prefix: string) => {
575+
// $FlowIgnore[prop-missing]
576+
const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
577+
// Override the target URL
578+
if (typeof permalink === 'string') {
579+
metadata.target = permalink;
580+
}
581+
return metadata;
582+
};
583+
} else {
584+
// This is not a server action, so the permalink argument has
585+
// no effect. The form will have to be hydrated before it's submitted.
586+
}
587+
588+
return [initialState, dispatch];
564589
}
565590

566591
function useId(): string {

0 commit comments

Comments
 (0)