Skip to content

Commit 053fe9d

Browse files
acdliteAndyPengc12
authored andcommitted
useFormState: MPA submissions to a different page (facebook#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.
1 parent 3ee75d5 commit 053fe9d

File tree

2 files changed

+134
-17
lines changed

2 files changed

+134
-17
lines changed

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,90 @@ describe('ReactFlightDOMForm', () => {
603603
expect(container.textContent).toBe('111');
604604
});
605605

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+
606690
// @gate enableFormActions
607691
// @gate enableAsyncActions
608692
it('useFormState can change the action URL with the `permalink` argument', async () => {

packages/react-server/src/ReactFizzHooks.js

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,20 @@ function useOptimistic<S, A>(
586586
return [passthrough, unsupportedSetOptimisticState];
587587
}
588588

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+
589603
function useFormState<S, P>(
590604
action: (S, P) => Promise<S>,
591605
initialState: S,
@@ -605,32 +619,42 @@ function useFormState<S, P>(
605619
// This is a server action. These have additional features to enable
606620
// MPA-style form submissions with progressive enhancement.
607621

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+
608631
// Determine the current form state. If we received state during an MPA form
609632
// submission, then we will reuse that, if the action identity matches.
610633
// Otherwise we'll use the initial state argument. We will emit a comment
611634
// marker into the stream that indicates whether the state was reused.
612635
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);
619637
const postbackFormState = getFormState(request);
620638
// $FlowIgnore[prop-missing]
621639
const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
622640
if (postbackFormState !== null && typeof isSignatureEqual === 'function') {
623-
const postbackKeyJSON = postbackFormState[1];
641+
const postbackKey = postbackFormState[1];
624642
const postbackReferenceId = postbackFormState[2];
625643
const postbackBoundArity = postbackFormState[3];
626644
if (
627-
postbackKeyJSON === keyJSON &&
628645
isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
629646
) {
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+
}
634658
}
635659
}
636660

@@ -648,17 +672,26 @@ function useFormState<S, P>(
648672
dispatch.$$FORM_ACTION = (prefix: string) => {
649673
const metadata: ReactCustomFormAction =
650674
boundAction.$$FORM_ACTION(prefix);
651-
const formData = metadata.data;
652-
if (formData) {
653-
formData.append('$ACTION_KEY', keyJSON);
654-
}
655675

656676
// Override the action URL
657677
if (permalink !== undefined) {
658678
if (__DEV__) {
659679
checkAttributeStringCoercion(permalink, 'target');
660680
}
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);
662695
}
663696
return metadata;
664697
};

0 commit comments

Comments
 (0)