@samitouri / QOS-React-1 / commits / ddff504695

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 {