Skip to content

Commit

Permalink
useFormState: MPA submissions to a different page (#27372)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Sep 14, 2023
1 parent a6e4791 commit caa716d
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,90 @@ describe('ReactFlightDOMForm', () => {
expect(container.textContent).toBe('111');
});

// @gate enableFormActions
// @gate enableAsyncActions
it('when permalink is provided, useFormState compares that instead of the keypath', async () => {
const serverAction = serverExports(async function action(
prevState,
formData,
) {
return prevState + 1;
});

function Form({action, permalink}) {
const [count, dispatch] = useFormState(action, 1, permalink);
return <form action={dispatch}>{count}</form>;
}

function Page1({action, permalink}) {
return <Form action={action} permalink={permalink} />;
}

function Page2({action, permalink}) {
return <Form action={action} permalink={permalink} />;
}

const Page1Ref = await clientExports(Page1);
const Page2Ref = await clientExports(Page2);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<Page1Ref action={serverAction} permalink="/permalink" />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);

expect(container.textContent).toBe('1');

// Submit the form
const form = container.getElementsByTagName('form')[0];
const {formState} = await submit(form);

// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';

// On the next page, the same server action is rendered again, but in
// a different component tree. However, because a permalink option was
// passed, the state should be preserved.
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
<Page2Ref action={serverAction} permalink="/permalink" />,
webpackMap,
);
const postbackResponse =
ReactServerDOMClient.createFromReadableStream(postbackRscStream);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{experimental_formState: formState},
);
await readIntoContainer(postbackSsrStream);

expect(container.textContent).toBe('2');

// Now submit the form again. This time, the permalink will be different, so
// the state is not preserved.
const form2 = container.getElementsByTagName('form')[0];
const {formState: formState2} = await submit(form2);

container.innerHTML = '';

const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
<Page1Ref action={serverAction} permalink="/some-other-permalink" />,
webpackMap,
);
const postbackResponse2 =
ReactServerDOMClient.createFromReadableStream(postbackRscStream2);
const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
postbackResponse2,
{experimental_formState: formState2},
);
await readIntoContainer(postbackSsrStream2);

// The state was reset because the permalink didn't match
expect(container.textContent).toBe('1');
});

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState can change the action URL with the `permalink` argument', async () => {
Expand Down
67 changes: 50 additions & 17 deletions packages/react-server/src/ReactFizzHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,20 @@ function useOptimistic<S, A>(
return [passthrough, unsupportedSetOptimisticState];
}

function createPostbackFormStateKey(
permalink: string | void,
componentKeyPath: KeyNode | null,
hookIndex: number,
): string {
if (permalink !== undefined) {
return 'p' + permalink;
} else {
// Append a node to the key path that represents the form state hook.
const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
return 'k' + JSON.stringify(keyPath);
}
}

function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
Expand All @@ -605,32 +619,42 @@ function useFormState<S, P>(
// This is a server action. These have additional features to enable
// MPA-style form submissions with progressive enhancement.

// TODO: If the same permalink is passed to multiple useFormStates, and
// they all have the same action signature, Fizz will pass the postback
// state to all of them. We should probably only pass it to the first one,
// and/or warn.

// The key is lazily generated and deduped so the that the keypath doesn't
// get JSON.stringify-ed unnecessarily, and at most once.
let nextPostbackStateKey = null;

// Determine the current form state. If we received state during an MPA form
// submission, then we will reuse that, if the action identity matches.
// Otherwise we'll use the initial state argument. We will emit a comment
// marker into the stream that indicates whether the state was reused.
let state = initialState;

// Append a node to the key path that represents the form state hook.
const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
const key: KeyNode = [componentKey, null, formStateHookIndex];
const keyJSON = JSON.stringify(key);

const componentKeyPath = (currentlyRenderingKeyPath: any);
const postbackFormState = getFormState(request);
// $FlowIgnore[prop-missing]
const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
if (postbackFormState !== null && typeof isSignatureEqual === 'function') {
const postbackKeyJSON = postbackFormState[1];
const postbackKey = postbackFormState[1];
const postbackReferenceId = postbackFormState[2];
const postbackBoundArity = postbackFormState[3];
if (
postbackKeyJSON === keyJSON &&
isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
) {
// This was a match
formStateMatchingIndex = formStateHookIndex;
// Reuse the state that was submitted by the form.
state = postbackFormState[0];
nextPostbackStateKey = createPostbackFormStateKey(
permalink,
componentKeyPath,
formStateHookIndex,
);
if (postbackKey === nextPostbackStateKey) {
// This was a match
formStateMatchingIndex = formStateHookIndex;
// Reuse the state that was submitted by the form.
state = postbackFormState[0];
}
}
}

Expand All @@ -648,17 +672,26 @@ function useFormState<S, P>(
dispatch.$$FORM_ACTION = (prefix: string) => {
const metadata: ReactCustomFormAction =
boundAction.$$FORM_ACTION(prefix);
const formData = metadata.data;
if (formData) {
formData.append('$ACTION_KEY', keyJSON);
}

// Override the action URL
if (permalink !== undefined) {
if (__DEV__) {
checkAttributeStringCoercion(permalink, 'target');
}
metadata.action = permalink + '';
permalink += '';
metadata.action = permalink;
}

const formData = metadata.data;
if (formData) {
if (nextPostbackStateKey === null) {
nextPostbackStateKey = createPostbackFormStateKey(
permalink,
componentKeyPath,
formStateHookIndex,
);
}
formData.append('$ACTION_KEY', nextPostbackStateKey);
}
return metadata;
};
Expand Down

0 comments on commit caa716d

Please sign in to comment.