diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js index 8116216fe8c6f..7bcd16b3ecfd7 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -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
{count}
; + } + + function Page1({action, permalink}) { + return
; + } + + function Page2({action, permalink}) { + return ; + } + + const Page1Ref = await clientExports(Page1); + const Page2Ref = await clientExports(Page2); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + 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( + , + 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( + , + 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 () => { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index f006c87c3369a..19256491e12d1 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -586,6 +586,20 @@ function useOptimistic( 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( action: (S, P) => Promise, initialState: S, @@ -605,32 +619,42 @@ function useFormState( // 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]; + } } } @@ -648,17 +672,26 @@ function useFormState( 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; };