@samitouri / QOS-React-2 / commits / 95c9554bc7

useFormState: Compare action signatures when reusing form state (#27370)

During an MPA form submission, useFormState should only reuse the form state if same action is passed both times. (We also compare the key paths.) We compare the identity of the inner closure function, disregarding the value of the bound arguments. That way you can pass an inline Server Action closure: ```js function FormContainer({maxLength}) { function submitAction(prevState, formData) { 'use server' if (formData.get('field').length > maxLength) { return { errorMsg: 'Too many characters' }; } // ... } return <Form submitAction={submitAction} /> } ```

Andrew Clark committed Sep 13, 2023 at 20:46 UTC 95c9554bc72813b0ee2b028774bb7cf0482887ba
14 files changed +377 -89
packages/react-client/src/ReactFlightReplyClient.js
+65
@@ -9,6 +9,7 @@
9
10 import type {
11 Thenable,
12 + PendingThenable,
13 FulfilledThenable,
14 RejectedThenable,
15 ReactCustomFormAction,
@@ -489,6 +490,69 @@ export function encodeFormAction(
490 };
491 }
492
493 +function isSignatureEqual(
494 + this: any => Promise<any>,
495 + referenceId: ServerReferenceId,
496 + numberOfBoundArgs: number,
497 +): boolean {
498 + const reference = knownServerReferences.get(this);
499 + if (!reference) {
500 + throw new Error(
501 + 'Tried to encode a Server Action from a different instance than the encoder is from. ' +
502 + 'This is a bug in React.',
503 + );
504 + }
505 + if (reference.id !== referenceId) {
506 + // These are different functions.
507 + return false;
508 + }
509 + // Now check if the number of bound arguments is the same.
510 + const boundPromise = reference.bound;
511 + if (boundPromise === null) {
512 + // No bound arguments.
513 + return numberOfBoundArgs === 0;
514 + }
515 + // Unwrap the bound arguments array by suspending, if necessary. As with
516 + // encodeFormData, this means isSignatureEqual can only be called while React
517 + // is rendering.
518 + switch (boundPromise.status) {
519 + case 'fulfilled': {
520 + const boundArgs = boundPromise.value;
521 + return boundArgs.length === numberOfBoundArgs;
522 + }
523 + case 'pending': {
524 + throw boundPromise;
525 + }
526 + case 'rejected': {
527 + throw boundPromise.reason;
528 + }
529 + default: {
530 + if (typeof boundPromise.status === 'string') {
531 + // Only instrument the thenable if the status if not defined.
532 + } else {
533 + const pendingThenable: PendingThenable<Array<any>> =
534 + (boundPromise: any);
535 + pendingThenable.status = 'pending';
536 + pendingThenable.then(
537 + (boundArgs: Array<any>) => {
538 + const fulfilledThenable: FulfilledThenable<Array<any>> =
539 + (boundPromise: any);
540 + fulfilledThenable.status = 'fulfilled';
541 + fulfilledThenable.value = boundArgs;
542 + },
543 + (error: mixed) => {
544 + const rejectedThenable: RejectedThenable<number> =
545 + (boundPromise: any);
546 + rejectedThenable.status = 'rejected';
547 + rejectedThenable.reason = error;
548 + },
549 + );
550 + }
551 + throw boundPromise;
552 + }
553 + }
554 +}
555 +
556 export function registerServerReference(
557 proxy: any,
558 reference: {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
@@ -499,6 +563,7 @@ export function registerServerReference(
563 // Only expose this in builds that would actually use it. Not needed on the client.
564 Object.defineProperties((proxy: any), {
565 $$FORM_ACTION: {value: encodeFormAction},
566 + $$IS_SIGNATURE_EQUAL: {value: isSignatureEqual},
567 bind: {value: bind},
568 });
569 }
packages/react-dom/src/client/ReactDOMRoot.js
+1 -1
@@ -57,7 +57,7 @@ export type HydrateRootOptions = {
57 unstable_transitionCallbacks?: TransitionTracingCallbacks,
58 identifierPrefix?: string,
59 onRecoverableError?: (error: mixed) => void,
60 - experimental_formState?: ReactFormState<any> | null,
60 + experimental_formState?: ReactFormState<any, any> | null,
61 ...
62 };
63
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+1 -1
@@ -41,7 +41,7 @@ type Options = {
41 onPostpone?: (reason: string) => void,
42 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
43 importMap?: ImportMap,
44 - experimental_formState?: ReactFormState<any> | null,
44 + experimental_formState?: ReactFormState<any, any> | null,
45 };
46
47 type ResumeOptions = {
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+1 -1
@@ -39,7 +39,7 @@ type Options = {
39 onPostpone?: (reason: string) => void,
40 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41 importMap?: ImportMap,
42 - experimental_formState?: ReactFormState<any> | null,
42 + experimental_formState?: ReactFormState<any, any> | null,
43 };
44
45 // TODO: Move to sub-classing ReadableStream.
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+1 -1
@@ -41,7 +41,7 @@ type Options = {
41 onPostpone?: (reason: string) => void,
42 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
43 importMap?: ImportMap,
44 - experimental_formState?: ReactFormState<any> | null,
44 + experimental_formState?: ReactFormState<any, any> | null,
45 };
46
47 type ResumeOptions = {
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+1 -1
@@ -54,7 +54,7 @@ type Options = {
54 onPostpone?: (reason: string) => void,
55 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
56 importMap?: ImportMap,
57 - experimental_formState?: ReactFormState<any> | null,
57 + experimental_formState?: ReactFormState<any, any> | null,
58 };
59
60 type ResumeOptions = {
packages/react-reconciler/src/ReactFiberReconciler.js
+1 -1
@@ -281,7 +281,7 @@ export function createHydrationContainer(
281 identifierPrefix: string,
282 onRecoverableError: (error: mixed) => void,
283 transitionCallbacks: null | TransitionTracingCallbacks,
284 - formState: ReactFormState<any> | null,
284 + formState: ReactFormState<any, any> | null,
285 ): OpaqueRoot {
286 const hydrate = true;
287 const root = createFiberRoot(
packages/react-reconciler/src/ReactFiberRoot.js
+2 -2
@@ -52,7 +52,7 @@ function FiberRootNode(
52 hydrate: any,
53 identifierPrefix: any,
54 onRecoverableError: any,
55 - formState: ReactFormState<any> | null,
55 + formState: ReactFormState<any, any> | null,
56 ) {
57 this.tag = tag;
58 this.containerInfo = containerInfo;
@@ -145,7 +145,7 @@ export function createFiberRoot(
145 identifierPrefix: string,
146 onRecoverableError: null | ((error: mixed) => void),
147 transitionCallbacks: null | TransitionTracingCallbacks,
148 - formState: ReactFormState<any> | null,
148 + formState: ReactFormState<any, any> | null,
149 ): FiberRoot {
150 // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
151 const root: FiberRoot = (new FiberRootNode(
packages/react-reconciler/src/ReactInternalTypes.js
+1 -1
@@ -272,7 +272,7 @@ type BaseFiberRootProperties = {
272 errorInfo: {digest?: ?string, componentStack?: ?string},
273 ) => void,
274
275 - formState: ReactFormState<any> | null,
275 + formState: ReactFormState<any, any> | null,
276 };
277
278 // The following attributes are only used by DevTools and are only present in DEV builds.
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+169 -1
@@ -67,7 +67,7 @@ describe('ReactFlightDOMForm', () => {
67 webpackServerMap,
68 );
69 const returnValue = boundAction();
70 - const formState = ReactServerDOMServer.decodeFormState(
70 + const formState = await ReactServerDOMServer.decodeFormState(
71 await returnValue,
72 formData,
73 webpackServerMap,
@@ -435,6 +435,174 @@ describe('ReactFlightDOMForm', () => {
435 }
436 });
437
438 + // @gate enableFormActions
439 + // @gate enableAsyncActions
440 + it(
441 + 'useFormState preserves state if arity is the same, but different ' +
442 + 'arguments are bound (i.e. inline closure)',
443 + async () => {
444 + const serverAction = serverExports(async function action(
445 + stepSize,
446 + prevState,
447 + formData,
448 + ) {
449 + return prevState + stepSize;
450 + });
451 +
452 + function Form({action}) {
453 + const [count, dispatch] = useFormState(action, 1);
454 + return <form action={dispatch}>{count}</form>;
455 + }
456 +
457 + function Client({action}) {
458 + return (
459 + <div>
460 + <Form action={action} />
461 + <Form action={action} />
462 + <Form action={action} />
463 + </div>
464 + );
465 + }
466 +
467 + const ClientRef = await clientExports(Client);
468 +
469 + const rscStream = ReactServerDOMServer.renderToReadableStream(
470 + // Note: `.bind` is the same as an inline closure with 'use server'
471 + <ClientRef action={serverAction.bind(null, 1)} />,
472 + webpackMap,
473 + );
474 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
475 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
476 + await readIntoContainer(ssrStream);
477 +
478 + expect(container.textContent).toBe('111');
479 +
480 + // There are three identical forms. We're going to submit the second one.
481 + const form = container.getElementsByTagName('form')[1];
482 + const {formState} = await submit(form);
483 +
484 + // Simulate an MPA form submission by resetting the container and
485 + // rendering again.
486 + container.innerHTML = '';
487 +
488 + // On the next page, the same server action is rendered again, but with
489 + // a different bound stepSize argument. We should treat this as the same
490 + // action signature.
491 + const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
492 + // Note: `.bind` is the same as an inline closure with 'use server'
493 + <ClientRef action={serverAction.bind(null, 5)} />,
494 + webpackMap,
495 + );
496 + const postbackResponse =
497 + ReactServerDOMClient.createFromReadableStream(postbackRscStream);
498 + const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
499 + postbackResponse,
500 + {experimental_formState: formState},
501 + );
502 + await readIntoContainer(postbackSsrStream);
503 +
504 + // The state should have been preserved because the action signatures are
505 + // the same. (Note that the amount increased by 1, because that was the
506 + // value of stepSize at the time the form was submitted)
507 + expect(container.textContent).toBe('121');
508 +
509 + // Now submit the form again. This time, the state should increase by 5
510 + // because the stepSize argument has changed.
511 + const form2 = container.getElementsByTagName('form')[1];
512 + const {formState: formState2} = await submit(form2);
513 +
514 + container.innerHTML = '';
515 +
516 + const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
517 + // Note: `.bind` is the same as an inline closure with 'use server'
518 + <ClientRef action={serverAction.bind(null, 5)} />,
519 + webpackMap,
520 + );
521 + const postbackResponse2 =
522 + ReactServerDOMClient.createFromReadableStream(postbackRscStream2);
523 + const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
524 + postbackResponse2,
525 + {experimental_formState: formState2},
526 + );
527 + await readIntoContainer(postbackSsrStream2);
528 +
529 + expect(container.textContent).toBe('171');
530 + },
531 + );
532 +
533 + // @gate enableFormActions
534 + // @gate enableAsyncActions
535 + it('useFormState does not reuse state if action signatures are different', async () => {
536 + // This is the same as the previous test, except instead of using bind to
537 + // configure the server action (i.e. a closure), it swaps the action.
538 + const increaseBy1 = serverExports(async function action(
539 + prevState,
540 + formData,
541 + ) {
542 + return prevState + 1;
543 + });
544 +
545 + const increaseBy5 = serverExports(async function action(
546 + prevState,
547 + formData,
548 + ) {
549 + return prevState + 5;
550 + });
551 +
552 + function Form({action}) {
553 + const [count, dispatch] = useFormState(action, 1);
554 + return <form action={dispatch}>{count}</form>;
555 + }
556 +
557 + function Client({action}) {
558 + return (
559 + <div>
560 + <Form action={action} />
561 + <Form action={action} />
562 + <Form action={action} />
563 + </div>
564 + );
565 + }
566 +
567 + const ClientRef = await clientExports(Client);
568 +
569 + const rscStream = ReactServerDOMServer.renderToReadableStream(
570 + <ClientRef action={increaseBy1} />,
571 + webpackMap,
572 + );
573 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
574 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
575 + await readIntoContainer(ssrStream);
576 +
577 + expect(container.textContent).toBe('111');
578 +
579 + // There are three identical forms. We're going to submit the second one.
580 + const form = container.getElementsByTagName('form')[1];
581 + const {formState} = await submit(form);
582 +
583 + // Simulate an MPA form submission by resetting the container and
584 + // rendering again.
585 + container.innerHTML = '';
586 +
587 + // On the next page, a different server action is rendered. It should not
588 + // reuse the state from the previous page.
589 + const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
590 + <ClientRef action={increaseBy5} />,
591 + webpackMap,
592 + );
593 + const postbackResponse =
594 + ReactServerDOMClient.createFromReadableStream(postbackRscStream);
595 + const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
596 + postbackResponse,
597 + {experimental_formState: formState},
598 + );
599 + await readIntoContainer(postbackSsrStream);
600 +
601 + // The state should not have been preserved because the action signatures
602 + // are not the same.
603 + expect(container.textContent).toBe('111');
604 + });
605 +
606 // @gate enableFormActions
607 // @gate enableAsyncActions
608 it('useFormState can change the action URL with the `permalink` argument', async () => {
packages/react-server/src/ReactFizzHooks.js
+67 -54
@@ -599,70 +599,83 @@ function useFormState<S, P>(
599 const formStateHookIndex = formStateCounter++;
600 const request: Request = (currentlyRenderingRequest: any);
601
602 - // Append a node to the key path that represents the form state hook.
603 - const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
604 - const key: KeyNode = [componentKey, null, formStateHookIndex];
605 - const keyJSON = JSON.stringify(key);
606 -
607 - // Get the form state. If we received form state from a previous page, then
608 - // we should reuse that, if the action identity matches. Otherwise we'll use
609 - // the initial state argument. We emit a comment marker into the stream
610 - // that indicates whether the state was reused.
611 - let state;
612 - const postbackFormState = getFormState(request);
613 - if (postbackFormState !== null) {
614 - const postbackKey = postbackFormState[1];
615 - // TODO: Compare the action identity, too
616 - // TODO: If a permalink is used, disregard the key and compare that instead.
617 - if (keyJSON === postbackKey) {
618 - // This was a match.
619 - formStateMatchingIndex = formStateHookIndex;
620 - // Reuse the state that was submitted by the form.
621 - state = postbackFormState[0];
622 - } else {
623 - state = initialState;
602 + // $FlowIgnore[prop-missing]
603 + const formAction = action.$$FORM_ACTION;
604 + if (typeof formAction === 'function') {
605 + // This is a server action. These have additional features to enable
606 + // MPA-style form submissions with progressive enhancement.
607 +
608 + // Determine the current form state. If we received state during an MPA form
609 + // submission, then we will reuse that, if the action identity matches.
610 + // Otherwise we'll use the initial state argument. We will emit a comment
611 + // marker into the stream that indicates whether the state was reused.
612 + 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 +
619 + const postbackFormState = getFormState(request);
620 + // $FlowIgnore[prop-missing]
621 + const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
622 + if (postbackFormState !== null && typeof isSignatureEqual === 'function') {
623 + const postbackKeyJSON = postbackFormState[1];
624 + const postbackReferenceId = postbackFormState[2];
625 + const postbackBoundArity = postbackFormState[3];
626 + if (
627 + postbackKeyJSON === keyJSON &&
628 + isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
629 + ) {
630 + // This was a match
631 + formStateMatchingIndex = formStateHookIndex;
632 + // Reuse the state that was submitted by the form.
633 + state = postbackFormState[0];
634 + }
635 }
625 - } else {
626 - // TODO: As an optimization, Fizz should only emit these markers if form
627 - // state is passed at the root.
628 - state = initialState;
629 - }
636
631 - // Bind the state to the first argument of the action.
632 - const boundAction = action.bind(null, state);
637 + // Bind the state to the first argument of the action.
638 + const boundAction = action.bind(null, state);
639
634 - // Wrap the action so the return value is void.
635 - const dispatch = (payload: P): void => {
636 - boundAction(payload);
637 - };
640 + // Wrap the action so the return value is void.
641 + const dispatch = (payload: P): void => {
642 + boundAction(payload);
643 + };
644
639 - // $FlowIgnore[prop-missing]
640 - if (typeof boundAction.$$FORM_ACTION === 'function') {
645 // $FlowIgnore[prop-missing]
642 - dispatch.$$FORM_ACTION = (prefix: string) => {
646 + if (typeof boundAction.$$FORM_ACTION === 'function') {
647 // $FlowIgnore[prop-missing]
644 - const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
645 -
646 - const formData = metadata.data;
647 - if (formData) {
648 - formData.append('$ACTION_KEY', keyJSON);
649 - }
648 + dispatch.$$FORM_ACTION = (prefix: string) => {
649 + const metadata: ReactCustomFormAction =
650 + boundAction.$$FORM_ACTION(prefix);
651 + const formData = metadata.data;
652 + if (formData) {
653 + formData.append('$ACTION_KEY', keyJSON);
654 + }
655
651 - // Override the action URL
652 - if (permalink !== undefined) {
653 - if (__DEV__) {
654 - checkAttributeStringCoercion(permalink, 'target');
656 + // Override the action URL
657 + if (permalink !== undefined) {
658 + if (__DEV__) {
659 + checkAttributeStringCoercion(permalink, 'target');
660 + }
661 + metadata.action = permalink + '';
662 }
656 - metadata.action = permalink + '';
657 - }
658 - return metadata;
659 - };
663 + return metadata;
664 + };
665 + }
666 +
667 + return [state, dispatch];
668 } else {
661 - // This is not a server action, so the permalink argument has
662 - // no effect. The form will have to be hydrated before it's submitted.
663 - }
669 + // This is not a server action, so the implementation is much simpler.
670
665 - return [state, dispatch];
671 + // Bind the state to the first argument of the action.
672 + const boundAction = action.bind(null, initialState);
673 + // Wrap the action so the return value is void.
674 + const dispatch = (payload: P): void => {
675 + boundAction(payload);
676 + };
677 + return [initialState, dispatch];
678 + }
679 }
680
681 function useId(): string {
packages/react-server/src/ReactFizzServer.js
+5 -3
@@ -313,7 +313,7 @@ export opaque type Request = {
313 // rendering - e.g. to the client. This is considered intentional and not an error.
314 onPostpone: (reason: string) => void,
315 // Form state that was the result of an MPA submission, if it was provided.
316 - formState: null | ReactFormState<any>,
316 + formState: null | ReactFormState<any, any>,
317 };
318
319 // This is a default heuristic for how to split up the HTML content into progressive
@@ -352,7 +352,7 @@ export function createRequest(
352 onShellError: void | ((error: mixed) => void),
353 onFatalError: void | ((error: mixed) => void),
354 onPostpone: void | ((reason: string) => void),
355 - formState: void | null | ReactFormState<any>,
355 + formState: void | null | ReactFormState<any, any>,
356 ): Request {
357 prepareHostDispatcher();
358 const pingedTasks: Array<Task> = [];
@@ -3095,7 +3095,9 @@ export function flushResources(request: Request): void {
3095 enqueueFlush(request);
3096 }
3097
3098 -export function getFormState(request: Request): ReactFormState<any> | null {
3098 +export function getFormState(
3099 + request: Request,
3100 +): ReactFormState<any, any> | null {
3101 return request.formState;
3102 }
3103
packages/react-server/src/ReactFlightActionServer.js
+59 -21
@@ -53,6 +53,28 @@ function loadServerReference<T>(
53 }
54 }
55
56 +function decodeBoundActionMetaData(
57 + body: FormData,
58 + serverManifest: ServerManifest,
59 + formFieldPrefix: string,
60 +): {id: ServerReferenceId, bound: null | Promise<Array<any>>} {
61 + // The data for this reference is encoded in multiple fields under this prefix.
62 + const actionResponse = createResponse(serverManifest, formFieldPrefix, body);
63 + close(actionResponse);
64 + const refPromise = getRoot<{
65 + id: ServerReferenceId,
66 + bound: null | Promise<Array<any>>,
67 + }>(actionResponse);
68 + // Force it to initialize
69 + // $FlowFixMe
70 + refPromise.then(() => {});
71 + if (refPromise.status !== 'fulfilled') {
72 + // $FlowFixMe
73 + throw refPromise.reason;
74 + }
75 + return refPromise.value;
76 +}
77 +
78 export function decodeAction<T>(
79 body: FormData,
80 serverManifest: ServerManifest,
@@ -73,25 +95,11 @@ export function decodeAction<T>(
95 // form action.
96 if (key.startsWith('$ACTION_REF_')) {
97 const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
76 - // The data for this reference is encoded in multiple fields under this prefix.
77 - const actionResponse = createResponse(
98 + const metaData = decodeBoundActionMetaData(
99 + body,
100 serverManifest,
101 formFieldPrefix,
80 - body,
102 );
82 - close(actionResponse);
83 - const refPromise = getRoot<{
84 - id: ServerReferenceId,
85 - bound: null | Promise<Array<any>>,
86 - }>(actionResponse);
87 - // Force it to initialize
88 - // $FlowFixMe
89 - refPromise.then(() => {});
90 - if (refPromise.status !== 'fulfilled') {
91 - // $FlowFixMe
92 - throw refPromise.reason;
93 - }
94 - const metaData = refPromise.value;
103 action = loadServerReference(serverManifest, metaData.id, metaData.bound);
104 return;
105 }
@@ -109,17 +117,47 @@ export function decodeAction<T>(
117 return action.then(fn => fn.bind(null, formData));
118 }
119
112 -// TODO: Should this be an async function to preserve the option in the future
113 -// to do async stuff in here? Would also make it consistent with decodeAction
120 export function decodeFormState<S>(
121 actionResult: S,
122 body: FormData,
123 serverManifest: ServerManifest,
118 -): ReactFormState<S> | null {
124 +): Promise<ReactFormState<S, ServerReferenceId> | null> {
125 const keyPath = body.get('$ACTION_KEY');
126 if (typeof keyPath !== 'string') {
127 // This form submission did not include any form state.
122 - return null;
128 + return Promise.resolve(null);
129 }
124 - return [actionResult, keyPath];
130 + // Search through the form data object to get the reference id and the number
131 + // of bound arguments. This repeats some of the work done in decodeAction.
132 + let metaData = null;
133 + // $FlowFixMe[prop-missing]
134 + body.forEach((value: string | File, key: string) => {
135 + if (key.startsWith('$ACTION_REF_')) {
136 + const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
137 + metaData = decodeBoundActionMetaData(
138 + body,
139 + serverManifest,
140 + formFieldPrefix,
141 + );
142 + }
143 + // We don't check for the simple $ACTION_ID_ case because form state actions
144 + // are always bound to the state argument.
145 + });
146 + if (metaData === null) {
147 + // Should be unreachable.
148 + return Promise.resolve(null);
149 + }
150 + const referenceId = metaData.id;
151 + return Promise.resolve(metaData.bound).then(bound => {
152 + if (bound === null) {
153 + // Should be unreachable because form state actions are always bound to the
154 + // state argument.
155 + return null;
156 + }
157 + // The form action dispatch method is always bound to the initial state.
158 + // But when comparing signatures, we compare to the original unbound action.
159 + // Subtract one from the arity to account for this.
160 + const boundArity = bound.length - 1;
161 + return [actionResult, keyPath, referenceId, boundArity];
162 + });
163 }
packages/shared/ReactTypes.js
+3 -1
@@ -178,7 +178,9 @@ export type ReactCustomFormAction = {
178 // This is an opaque type returned by decodeFormState on the server, but it's
179 // defined in this shared file because the same type is used by React on
180 // the client.
181 -export type ReactFormState<S> = [
181 +export type ReactFormState<S, ReferenceId> = [
182 S /* actual state value */,
183 string /* key path */,
184 + ReferenceId /* Server Reference ID */,
185 + number /* number of bound arguments */,
186 ];