Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useFormState: MPA submissions to a different page #27372

Merged
merged 1 commit into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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