Skip to content

Commit 1587eb8

Browse files
acdliteAndyPengc12
authored andcommitted
useFormState's permalink option changes form target (facebook#27302)
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 6fe78f4 commit 1587eb8

File tree

5 files changed

+179
-29
lines changed

5 files changed

+179
-29
lines changed

packages/react-dom-bindings/src/shared/ReactDOMFormActions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,13 @@ export function useFormStatus(): FormStatus {
7878
export function useFormState<S, P>(
7979
action: (S, P) => Promise<S>,
8080
initialState: S,
81-
url?: string,
81+
permalink?: string,
8282
): [S, (P) => void] {
8383
if (!(enableFormActions && enableAsyncActions)) {
8484
throw new Error('Not implemented.');
8585
} else {
8686
const dispatcher = resolveDispatcher();
8787
// $FlowFixMe[not-a-function] This is unstable, thus optional
88-
return dispatcher.useFormState(action, initialState, url);
88+
return dispatcher.useFormState(action, initialState, permalink);
8989
}
9090
}

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,7 +2008,7 @@ function formStateReducer<S>(oldState: S, newState: S): S {
20082008
function mountFormState<S, P>(
20092009
action: (S, P) => Promise<S>,
20102010
initialState: S,
2011-
url?: string,
2011+
permalink?: string,
20122012
): [S, (P) => void] {
20132013
// State hook. The state is stored in a thenable which is then unwrapped by
20142014
// the `use` algorithm during render.
@@ -2063,7 +2063,7 @@ function mountFormState<S, P>(
20632063
function updateFormState<S, P>(
20642064
action: (S, P) => Promise<S>,
20652065
initialState: S,
2066-
url?: string,
2066+
permalink?: string,
20672067
): [S, (P) => void] {
20682068
const stateHook = updateWorkInProgressHook();
20692069
const currentStateHook = ((currentHook: any): Hook);
@@ -2072,7 +2072,7 @@ function updateFormState<S, P>(
20722072
currentStateHook,
20732073
action,
20742074
initialState,
2075-
url,
2075+
permalink,
20762076
);
20772077
}
20782078

@@ -2081,7 +2081,7 @@ function updateFormStateImpl<S, P>(
20812081
currentStateHook: Hook,
20822082
action: (S, P) => Promise<S>,
20832083
initialState: S,
2084-
url?: string,
2084+
permalink?: string,
20852085
): [S, (P) => void] {
20862086
const [thenable] = updateReducerImpl<Thenable<S>, Thenable<S>>(
20872087
stateHook,
@@ -2121,7 +2121,7 @@ function formStateActionEffect<S, P>(
21212121
function rerenderFormState<S, P>(
21222122
action: (S, P) => Promise<S>,
21232123
initialState: S,
2124-
url?: string,
2124+
permalink?: string,
21252125
): [S, (P) => void] {
21262126
// Unlike useState, useFormState doesn't support render phase updates.
21272127
// Also unlike useState, we need to replay all pending updates again in case
@@ -2140,7 +2140,7 @@ function rerenderFormState<S, P>(
21402140
currentStateHook,
21412141
action,
21422142
initialState,
2143-
url,
2143+
permalink,
21442144
);
21452145
}
21462146

@@ -3628,11 +3628,11 @@ if (__DEV__) {
36283628
function useFormState<S, P>(
36293629
action: (S, P) => Promise<S>,
36303630
initialState: S,
3631-
url?: string,
3631+
permalink?: string,
36323632
): [S, (P) => void] {
36333633
currentHookNameInDev = 'useFormState';
36343634
mountHookTypesDev();
3635-
return mountFormState(action, initialState, url);
3635+
return mountFormState(action, initialState, permalink);
36363636
};
36373637
}
36383638
if (enableAsyncActions) {
@@ -3798,11 +3798,11 @@ if (__DEV__) {
37983798
function useFormState<S, P>(
37993799
action: (S, P) => Promise<S>,
38003800
initialState: S,
3801-
url?: string,
3801+
permalink?: string,
38023802
): [S, (P) => void] {
38033803
currentHookNameInDev = 'useFormState';
38043804
updateHookTypesDev();
3805-
return mountFormState(action, initialState, url);
3805+
return mountFormState(action, initialState, permalink);
38063806
};
38073807
}
38083808
if (enableAsyncActions) {
@@ -3970,11 +3970,11 @@ if (__DEV__) {
39703970
function useFormState<S, P>(
39713971
action: (S, P) => Promise<S>,
39723972
initialState: S,
3973-
url?: string,
3973+
permalink?: string,
39743974
): [S, (P) => void] {
39753975
currentHookNameInDev = 'useFormState';
39763976
updateHookTypesDev();
3977-
return updateFormState(action, initialState, url);
3977+
return updateFormState(action, initialState, permalink);
39783978
};
39793979
}
39803980
if (enableAsyncActions) {
@@ -4142,11 +4142,11 @@ if (__DEV__) {
41424142
function useFormState<S, P>(
41434143
action: (S, P) => Promise<S>,
41444144
initialState: S,
4145-
url?: string,
4145+
permalink?: string,
41464146
): [S, (P) => void] {
41474147
currentHookNameInDev = 'useFormState';
41484148
updateHookTypesDev();
4149-
return rerenderFormState(action, initialState, url);
4149+
return rerenderFormState(action, initialState, permalink);
41504150
};
41514151
}
41524152
if (enableAsyncActions) {
@@ -4335,12 +4335,12 @@ if (__DEV__) {
43354335
function useFormState<S, P>(
43364336
action: (S, P) => Promise<S>,
43374337
initialState: S,
4338-
url?: string,
4338+
permalink?: string,
43394339
): [S, (P) => void] {
43404340
currentHookNameInDev = 'useFormState';
43414341
warnInvalidHookAccess();
43424342
mountHookTypesDev();
4343-
return mountFormState(action, initialState, url);
4343+
return mountFormState(action, initialState, permalink);
43444344
};
43454345
}
43464346
if (enableAsyncActions) {
@@ -4533,12 +4533,12 @@ if (__DEV__) {
45334533
function useFormState<S, P>(
45344534
action: (S, P) => Promise<S>,
45354535
initialState: S,
4536-
url?: string,
4536+
permalink?: string,
45374537
): [S, (P) => void] {
45384538
currentHookNameInDev = 'useFormState';
45394539
warnInvalidHookAccess();
45404540
updateHookTypesDev();
4541-
return updateFormState(action, initialState, url);
4541+
return updateFormState(action, initialState, permalink);
45424542
};
45434543
}
45444544
if (enableAsyncActions) {
@@ -4731,12 +4731,12 @@ if (__DEV__) {
47314731
function useFormState<S, P>(
47324732
action: (S, P) => Promise<S>,
47334733
initialState: S,
4734-
url?: string,
4734+
permalink?: string,
47354735
): [S, (P) => void] {
47364736
currentHookNameInDev = 'useFormState';
47374737
warnInvalidHookAccess();
47384738
updateHookTypesDev();
4739-
return rerenderFormState(action, initialState, url);
4739+
return rerenderFormState(action, initialState, permalink);
47404740
};
47414741
}
47424742
if (enableAsyncActions) {

packages/react-reconciler/src/ReactInternalTypes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export type Dispatcher = {
417417
useFormState?: <S, P>(
418418
action: (S, P) => Promise<S>,
419419
initialState: S,
420-
url?: string,
420+
permalink?: string,
421421
) => [S, (P) => void],
422422
};
423423

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

Lines changed: 121 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,123 @@ 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+
});
391+
392+
// @gate enableFormActions
393+
// @gate enableAsyncActions
394+
it('useFormState `permalink` is coerced to string', async () => {
395+
const serverAction = serverExports(function action(prevState) {
396+
return {state: prevState.count + 1};
397+
});
398+
399+
class Permalink {
400+
toString() {
401+
return '/permalink';
402+
}
403+
}
404+
405+
const permalink = new Permalink();
406+
407+
const initialState = {count: 1};
408+
function Client({action}) {
409+
const [state, dispatch] = useFormState(action, initialState, permalink);
410+
return (
411+
<form action={dispatch}>
412+
<span>Count: {state.count}</span>
413+
</form>
414+
);
415+
}
416+
const ClientRef = await clientExports(Client);
417+
418+
const rscStream = ReactServerDOMServer.renderToReadableStream(
419+
<ClientRef action={serverAction} />,
420+
webpackMap,
421+
);
422+
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
423+
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
424+
await readIntoContainer(ssrStream);
425+
426+
const form = container.firstChild;
427+
const span = container.getElementsByTagName('span')[0];
428+
expect(span.textContent).toBe('Count: 1');
429+
430+
expect(form.target).toBe('/permalink');
431+
});
311432
});

packages/react-server/src/ReactFizzHooks.js

Lines changed: 35 additions & 6 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';
@@ -40,6 +41,7 @@ import {
4041
REACT_CONTEXT_TYPE,
4142
REACT_MEMO_CACHE_SENTINEL,
4243
} from 'shared/ReactSymbols';
44+
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
4345

4446
type BasicStateAction<S> = (S => S) | S;
4547
type Dispatch<A> = A => void;
@@ -542,10 +544,6 @@ function unsupportedSetOptimisticState() {
542544
throw new Error('Cannot update optimistic state while rendering.');
543545
}
544546

545-
function unsupportedDispatchFormState() {
546-
throw new Error('Cannot update form state while rendering.');
547-
}
548-
549547
function useOptimistic<S, A>(
550548
passthrough: S,
551549
reducer: ?(S, A) => S,
@@ -557,10 +555,41 @@ function useOptimistic<S, A>(
557555
function useFormState<S, P>(
558556
action: (S, P) => Promise<S>,
559557
initialState: S,
560-
url?: string,
558+
permalink?: string,
561559
): [S, (P) => void] {
562560
resolveCurrentlyRenderingComponent();
563-
return [initialState, unsupportedDispatchFormState];
561+
562+
// Bind the initial state to the first argument of the action.
563+
// TODO: Use the keypath (or permalink) to check if there's matching state
564+
// from the previous page.
565+
const boundAction = action.bind(null, initialState);
566+
567+
// Wrap the action so the return value is void.
568+
const dispatch = (payload: P): void => {
569+
boundAction(payload);
570+
};
571+
572+
// $FlowIgnore[prop-missing]
573+
if (typeof boundAction.$$FORM_ACTION === 'function') {
574+
// $FlowIgnore[prop-missing]
575+
dispatch.$$FORM_ACTION = (prefix: string) => {
576+
// $FlowIgnore[prop-missing]
577+
const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
578+
// Override the target URL
579+
if (permalink !== undefined) {
580+
if (__DEV__) {
581+
checkAttributeStringCoercion(permalink, 'target');
582+
}
583+
metadata.target = permalink + '';
584+
}
585+
return metadata;
586+
};
587+
} else {
588+
// This is not a server action, so the permalink argument has
589+
// no effect. The form will have to be hydrated before it's submitted.
590+
}
591+
592+
return [initialState, dispatch];
564593
}
565594

566595
function useId(): string {

0 commit comments

Comments
 (0)