@samitouri / QOS-React / commits / caa716d50b

useFormState: MPA submissions to a different page (#27372)

The permalink option of useFormState controls which page the form is submitted to during an MPA form submission (i.e. a submission that happens before hydration, or when JS is disabled). If the same useFormState appears on the resulting page, and the permalink option matches, it should receive the form state from the submission despite the fact that the keypaths do not match. So the logic for whether a form state instance is considered a match is: - Both instances must be passed the same action signature - If a permalink is provided, the permalinks must match. - If a permalink is not provided, the keypaths must match. Currently, if there are multiple matching useFormStates, they will all match and receive the form state. We should probably only match the first one, and/or warn when this happens. I've left this as a TODO for now, pending further discussion.

Andrew Clark committed Sep 14, 2023 at 01:07 UTC caa716d50bdeef3a1ac5e3e0cfcc14f4d91f2028
2 files changed +134 -17
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+84
@@ -603,6 +603,90 @@ describe('ReactFlightDOMForm', () => {
603 expect(container.textContent).toBe('111');
604 });
605
606 + // @gate enableFormActions
607 + // @gate enableAsyncActions
608 + it('when permalink is provided, useFormState compares that instead of the keypath', async () => {
609 + const serverAction = serverExports(async function action(
610 + prevState,
611 + formData,
612 + ) {
613 + return prevState + 1;
614 + });
615 +
616 + function Form({action, permalink}) {
617 + const [count, dispatch] = useFormState(action, 1, permalink);
618 + return <form action={dispatch}>{count}</form>;
619 + }
620 +
621 + function Page1({action, permalink}) {
622 + return <Form action={action} permalink={permalink} />;
623 + }
624 +
625 + function Page2({action, permalink}) {
626 + return <Form action={action} permalink={permalink} />;
627 + }
628 +
629 + const Page1Ref = await clientExports(Page1);
630 + const Page2Ref = await clientExports(Page2);
631 +
632 + const rscStream = ReactServerDOMServer.renderToReadableStream(
633 + <Page1Ref action={serverAction} permalink="/permalink" />,
634 + webpackMap,
635 + );
636 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
637 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
638 + await readIntoContainer(ssrStream);
639 +
640 + expect(container.textContent).toBe('1');
641 +
642 + // Submit the form
643 + const form = container.getElementsByTagName('form')[0];
644 + const {formState} = await submit(form);
645 +
646 + // Simulate an MPA form submission by resetting the container and
647 + // rendering again.
648 + container.innerHTML = '';
649 +
650 + // On the next page, the same server action is rendered again, but in
651 + // a different component tree. However, because a permalink option was
652 + // passed, the state should be preserved.
653 + const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
654 + <Page2Ref action={serverAction} permalink="/permalink" />,
655 + webpackMap,
656 + );
657 + const postbackResponse =
658 + ReactServerDOMClient.createFromReadableStream(postbackRscStream);
659 + const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
660 + postbackResponse,
661 + {experimental_formState: formState},
662 + );
663 + await readIntoContainer(postbackSsrStream);
664 +
665 + expect(container.textContent).toBe('2');
666 +
667 + // Now submit the form again. This time, the permalink will be different, so
668 + // the state is not preserved.
669 + const form2 = container.getElementsByTagName('form')[0];
670 + const {formState: formState2} = await submit(form2);
671 +
672 + container.innerHTML = '';
673 +
674 + const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
675 + <Page1Ref action={serverAction} permalink="/some-other-permalink" />,
676 + webpackMap,
677 + );
678 + const postbackResponse2 =
679 + ReactServerDOMClient.createFromReadableStream(postbackRscStream2);
680 + const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
681 + postbackResponse2,
682 + {experimental_formState: formState2},
683 + );
684 + await readIntoContainer(postbackSsrStream2);
685 +
686 + // The state was reset because the permalink didn't match
687 + expect(container.textContent).toBe('1');
688 + });
689 +
690 // @gate enableFormActions
691 // @gate enableAsyncActions
692 it('useFormState can change the action URL with the `permalink` argument', async () => {
packages/react-server/src/ReactFizzHooks.js
+50 -17
@@ -586,6 +586,20 @@ function useOptimistic<S, A>(
586 return [passthrough, unsupportedSetOptimisticState];
587 }
588
589 +function createPostbackFormStateKey(
590 + permalink: string | void,
591 + componentKeyPath: KeyNode | null,
592 + hookIndex: number,
593 +): string {
594 + if (permalink !== undefined) {
595 + return 'p' + permalink;
596 + } else {
597 + // Append a node to the key path that represents the form state hook.
598 + const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
599 + return 'k' + JSON.stringify(keyPath);
600 + }
601 +}
602 +
603 function useFormState<S, P>(
604 action: (S, P) => Promise<S>,
605 initialState: S,
@@ -605,32 +619,42 @@ function useFormState<S, P>(
619 // This is a server action. These have additional features to enable
620 // MPA-style form submissions with progressive enhancement.
621
622 + // TODO: If the same permalink is passed to multiple useFormStates, and
623 + // they all have the same action signature, Fizz will pass the postback
624 + // state to all of them. We should probably only pass it to the first one,
625 + // and/or warn.
626 +
627 + // The key is lazily generated and deduped so the that the keypath doesn't
628 + // get JSON.stringify-ed unnecessarily, and at most once.
629 + let nextPostbackStateKey = null;
630 +
631 // Determine the current form state. If we received state during an MPA form
632 // submission, then we will reuse that, if the action identity matches.
633 // Otherwise we'll use the initial state argument. We will emit a comment
634 // marker into the stream that indicates whether the state was reused.
635 let state = initialState;
613 -
614 - // Append a node to the key path that represents the form state hook.
615 - const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
616 - const key: KeyNode = [componentKey, null, formStateHookIndex];
617 - const keyJSON = JSON.stringify(key);
618 -
636 + const componentKeyPath = (currentlyRenderingKeyPath: any);
637 const postbackFormState = getFormState(request);
638 // $FlowIgnore[prop-missing]
639 const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
640 if (postbackFormState !== null && typeof isSignatureEqual === 'function') {
623 - const postbackKeyJSON = postbackFormState[1];
641 + const postbackKey = postbackFormState[1];
642 const postbackReferenceId = postbackFormState[2];
643 const postbackBoundArity = postbackFormState[3];
644 if (
627 - postbackKeyJSON === keyJSON &&
645 isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
646 ) {
630 - // This was a match
631 - formStateMatchingIndex = formStateHookIndex;
632 - // Reuse the state that was submitted by the form.
633 - state = postbackFormState[0];
647 + nextPostbackStateKey = createPostbackFormStateKey(
648 + permalink,
649 + componentKeyPath,
650 + formStateHookIndex,
651 + );
652 + if (postbackKey === nextPostbackStateKey) {
653 + // This was a match
654 + formStateMatchingIndex = formStateHookIndex;
655 + // Reuse the state that was submitted by the form.
656 + state = postbackFormState[0];
657 + }
658 }
659 }
660
@@ -648,17 +672,26 @@ function useFormState<S, P>(
672 dispatch.$$FORM_ACTION = (prefix: string) => {
673 const metadata: ReactCustomFormAction =
674 boundAction.$$FORM_ACTION(prefix);
651 - const formData = metadata.data;
652 - if (formData) {
653 - formData.append('$ACTION_KEY', keyJSON);
654 - }
675
676 // Override the action URL
677 if (permalink !== undefined) {
678 if (__DEV__) {
679 checkAttributeStringCoercion(permalink, 'target');
680 }
661 - metadata.action = permalink + '';
681 + permalink += '';
682 + metadata.action = permalink;
683 + }
684 +
685 + const formData = metadata.data;
686 + if (formData) {
687 + if (nextPostbackStateKey === null) {
688 + nextPostbackStateKey = createPostbackFormStateKey(
689 + permalink,
690 + componentKeyPath,
691 + formStateHookIndex,
692 + );
693 + }
694 + formData.append('$ACTION_KEY', nextPostbackStateKey);
695 }
696 return metadata;
697 };