useFormState's permalink option changes form target (#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.
Andrew Clark committed
Aug 29, 2023 at 11:58 UTC
ddff504695f33c19e8c0792bff82bd8f8b8f7c05
5 files changed
+179
-29
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+2
-2
@@ -78,13 +78,13 @@ export function useFormStatus(): FormStatus {
78
export function useFormState<S, P>(
79
action: (S, P) => Promise<S>,
80
initialState: S,
81
- url?: string,
81
+ permalink?: string,
82
): [S, (P) => void] {
83
if (!(enableFormActions && enableAsyncActions)) {
84
throw new Error('Not implemented.');
85
} else {
86
const dispatcher = resolveDispatcher();
87
// $FlowFixMe[not-a-function] This is unstable, thus optional
88
- return dispatcher.useFormState(action, initialState, url);
88
+ return dispatcher.useFormState(action, initialState, permalink);
89
}
90
}
packages/react-reconciler/src/ReactFiberHooks.js
+20
-20
@@ -2008,7 +2008,7 @@ function formStateReducer<S>(oldState: S, newState: S): S {
2008
function mountFormState<S, P>(
2009
action: (S, P) => Promise<S>,
2010
initialState: S,
2011
- url?: string,
2011
+ permalink?: string,
2012
): [S, (P) => void] {
2013
// State hook. The state is stored in a thenable which is then unwrapped by
2014
// the `use` algorithm during render.
@@ -2063,7 +2063,7 @@ function mountFormState<S, P>(
2063
function updateFormState<S, P>(
2064
action: (S, P) => Promise<S>,
2065
initialState: S,
2066
- url?: string,
2066
+ permalink?: string,
2067
): [S, (P) => void] {
2068
const stateHook = updateWorkInProgressHook();
2069
const currentStateHook = ((currentHook: any): Hook);
@@ -2072,7 +2072,7 @@ function updateFormState<S, P>(
2072
currentStateHook,
2073
action,
2074
initialState,
2075
- url,
2075
+ permalink,
2076
);
2077
}
2078
@@ -2081,7 +2081,7 @@ function updateFormStateImpl<S, P>(
2081
currentStateHook: Hook,
2082
action: (S, P) => Promise<S>,
2083
initialState: S,
2084
- url?: string,
2084
+ permalink?: string,
2085
): [S, (P) => void] {
2086
const [thenable] = updateReducerImpl<Thenable<S>, Thenable<S>>(
2087
stateHook,
@@ -2121,7 +2121,7 @@ function formStateActionEffect<S, P>(
2121
function rerenderFormState<S, P>(
2122
action: (S, P) => Promise<S>,
2123
initialState: S,
2124
- url?: string,
2124
+ permalink?: string,
2125
): [S, (P) => void] {
2126
// Unlike useState, useFormState doesn't support render phase updates.
2127
// Also unlike useState, we need to replay all pending updates again in case
@@ -2140,7 +2140,7 @@ function rerenderFormState<S, P>(
2140
currentStateHook,
2141
action,
2142
initialState,
2143
- url,
2143
+ permalink,
2144
);
2145
}
2146
@@ -3628,11 +3628,11 @@ if (__DEV__) {
3628
function useFormState<S, P>(
3629
action: (S, P) => Promise<S>,
3630
initialState: S,
3631
- url?: string,
3631
+ permalink?: string,
3632
): [S, (P) => void] {
3633
currentHookNameInDev = 'useFormState';
3634
mountHookTypesDev();
3635
- return mountFormState(action, initialState, url);
3635
+ return mountFormState(action, initialState, permalink);
3636
};
3637
}
3638
if (enableAsyncActions) {
@@ -3798,11 +3798,11 @@ if (__DEV__) {
3798
function useFormState<S, P>(
3799
action: (S, P) => Promise<S>,
3800
initialState: S,
3801
- url?: string,
3801
+ permalink?: string,
3802
): [S, (P) => void] {
3803
currentHookNameInDev = 'useFormState';
3804
updateHookTypesDev();
3805
- return mountFormState(action, initialState, url);
3805
+ return mountFormState(action, initialState, permalink);
3806
};
3807
}
3808
if (enableAsyncActions) {
@@ -3970,11 +3970,11 @@ if (__DEV__) {
3970
function useFormState<S, P>(
3971
action: (S, P) => Promise<S>,
3972
initialState: S,
3973
- url?: string,
3973
+ permalink?: string,
3974
): [S, (P) => void] {
3975
currentHookNameInDev = 'useFormState';
3976
updateHookTypesDev();
3977
- return updateFormState(action, initialState, url);
3977
+ return updateFormState(action, initialState, permalink);
3978
};
3979
}
3980
if (enableAsyncActions) {
@@ -4142,11 +4142,11 @@ if (__DEV__) {
4142
function useFormState<S, P>(
4143
action: (S, P) => Promise<S>,
4144
initialState: S,
4145
- url?: string,
4145
+ permalink?: string,
4146
): [S, (P) => void] {
4147
currentHookNameInDev = 'useFormState';
4148
updateHookTypesDev();
4149
- return rerenderFormState(action, initialState, url);
4149
+ return rerenderFormState(action, initialState, permalink);
4150
};
4151
}
4152
if (enableAsyncActions) {
@@ -4335,12 +4335,12 @@ if (__DEV__) {
4335
function useFormState<S, P>(
4336
action: (S, P) => Promise<S>,
4337
initialState: S,
4338
- url?: string,
4338
+ permalink?: string,
4339
): [S, (P) => void] {
4340
currentHookNameInDev = 'useFormState';
4341
warnInvalidHookAccess();
4342
mountHookTypesDev();
4343
- return mountFormState(action, initialState, url);
4343
+ return mountFormState(action, initialState, permalink);
4344
};
4345
}
4346
if (enableAsyncActions) {
@@ -4533,12 +4533,12 @@ if (__DEV__) {
4533
function useFormState<S, P>(
4534
action: (S, P) => Promise<S>,
4535
initialState: S,
4536
- url?: string,
4536
+ permalink?: string,
4537
): [S, (P) => void] {
4538
currentHookNameInDev = 'useFormState';
4539
warnInvalidHookAccess();
4540
updateHookTypesDev();
4541
- return updateFormState(action, initialState, url);
4541
+ return updateFormState(action, initialState, permalink);
4542
};
4543
}
4544
if (enableAsyncActions) {
@@ -4731,12 +4731,12 @@ if (__DEV__) {
4731
function useFormState<S, P>(
4732
action: (S, P) => Promise<S>,
4733
initialState: S,
4734
- url?: string,
4734
+ permalink?: string,
4735
): [S, (P) => void] {
4736
currentHookNameInDev = 'useFormState';
4737
warnInvalidHookAccess();
4738
updateHookTypesDev();
4739
- return rerenderFormState(action, initialState, url);
4739
+ return rerenderFormState(action, initialState, permalink);
4740
};
4741
}
4742
if (enableAsyncActions) {
packages/react-reconciler/src/ReactInternalTypes.js
+1
-1
@@ -417,7 +417,7 @@ export type Dispatcher = {
417
useFormState?: <S, P>(
418
action: (S, P) => Promise<S>,
419
initialState: S,
420
- url?: string,
420
+ permalink?: string,
421
) => [S, (P) => void],
422
};
423
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+121
@@ -30,6 +30,7 @@ let React;
30
let ReactDOMServer;
31
let ReactServerDOMServer;
32
let ReactServerDOMClient;
33
+let useFormState;
34
35
describe('ReactFlightDOMForm', () => {
36
beforeEach(() => {
@@ -47,6 +48,7 @@ describe('ReactFlightDOMForm', () => {
48
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
49
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
50
ReactDOMServer = require('react-dom/server.edge');
51
+ useFormState = require('react-dom').experimental_useFormState;
52
container = document.createElement('div');
53
document.body.appendChild(container);
54
});
@@ -308,4 +310,123 @@ describe('ReactFlightDOMForm', () => {
310
expect(result).toBe('hello');
311
expect(foo).toBe('barobject');
312
});
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
+ });
432
});
packages/react-server/src/ReactFizzHooks.js
+35
-6
@@ -14,6 +14,7 @@ import type {
14
StartTransitionOptions,
15
Thenable,
16
Usable,
17
+ ReactCustomFormAction,
18
} from 'shared/ReactTypes';
19
20
import type {ResumableState} from './ReactFizzConfig';
@@ -40,6 +41,7 @@ import {
41
REACT_CONTEXT_TYPE,
42
REACT_MEMO_CACHE_SENTINEL,
43
} from 'shared/ReactSymbols';
44
+import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
45
46
type BasicStateAction<S> = (S => S) | S;
47
type Dispatch<A> = A => void;
@@ -542,10 +544,6 @@ function unsupportedSetOptimisticState() {
544
throw new Error('Cannot update optimistic state while rendering.');
545
}
546
545
-function unsupportedDispatchFormState() {
546
- throw new Error('Cannot update form state while rendering.');
547
-}
548
-
547
function useOptimistic<S, A>(
548
passthrough: S,
549
reducer: ?(S, A) => S,
@@ -557,10 +555,41 @@ function useOptimistic<S, A>(
555
function useFormState<S, P>(
556
action: (S, P) => Promise<S>,
557
initialState: S,
560
- url?: string,
558
+ permalink?: string,
559
): [S, (P) => void] {
560
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];
593
}
594
595
function useId(): string {