@samitouri / QOS-React / commits / bf449ee74e

Replay Client Actions After Hydration (#26716)

We used to have Event Replaying for any kind of Discrete event where we'd track any event after hydrateRoot and before the async code/data has loaded in to hydrate the target. However, this didn't really work out because code inside event handlers are expected to be able to synchronously read the state of the world at the time they're invoked. If we replay discrete events later, the mutable state around them like selection or form state etc. may have changed. This limitation doesn't apply to Client Actions: - They're expected to be async functions that themselves work asynchronously. They're conceptually also in the "navigation" events that happen after the "submit" events so they're already not synchronously even before the first `await`. - They're expected to operate mostly on the FormData as input which we can snapshot at the time of the event. This PR adds a bit of inline script to the Fizz runtime (or external runtime) to track any early submit events on the page - but only if the action URL is our placeholder `javascript:` URL. We track a queue of these on `document.$$reactFormReplay`. Then we replay them in order as they get hydrated and we get a handle on the Client Action function. I add the runtime to the `bootstrapScripts` phase in Fizz which is really technically a little too late, because on a large page, it might take a while to get to that script even if you have displayed the form. However, that's also true for external runtime. So there's a very short window we might miss an event but it's good enough and better than risking blocking display on this script. The main thing that makes the replaying difficult to reason about is that we can have multiple instance of React using this same queue. This would be very usual but you could have two different Reacts SSR:ing different parts of the tree and using around the same version. We don't have any coordinating ids for this. We could stash something on the form perhaps but given our current structure it's more difficult to get to the form instance in the commit phase and a naive solution wouldn't preserve ordering between forms. This solution isn't 100% guaranteed to preserve ordering between different React instances neither but should be in order within one instance which is the common case. The hard part is that we don't know what instance something will belong to until it hydrates. So to solve that I keep everything in the original queue while we wait, so that ordering is preserved until we know which instance it'll go into. I ended up doing a bunch of clever tricks to make this work. These could use a lot more tests than I have right now. Another thing that's tricky is that you can update the action before it's replayed but we actually want to invoke the old action if that happens. So we have to extract it even if we can't invoke it right now just so we get the one that was there during hydration.

Sebastian Markbåge committed Apr 25, 2023 at 10:22 UTC bf449ee74e5e98af3d08c87bb6a9f22a021f3522
11 files changed +396 -19
packages/react-dom-bindings/src/events/ReactDOMEventListener.js
+10 -4
@@ -225,19 +225,25 @@ export function dispatchEvent(
225 );
226 }
227
228 +export function findInstanceBlockingEvent(
229 + nativeEvent: AnyNativeEvent,
230 +): null | Container | SuspenseInstance {
231 + const nativeEventTarget = getEventTarget(nativeEvent);
232 + return findInstanceBlockingTarget(nativeEventTarget);
233 +}
234 +
235 export let return_targetInst: null | Fiber = null;
236
237 // Returns a SuspenseInstance or Container if it's blocked.
238 // The return_targetInst field above is conceptually part of the return value.
232 -export function findInstanceBlockingEvent(
233 - nativeEvent: AnyNativeEvent,
239 +export function findInstanceBlockingTarget(
240 + targetNode: Node,
241 ): null | Container | SuspenseInstance {
242 // TODO: Warn if _enabled is false.
243
244 return_targetInst = null;
245
239 - const nativeEventTarget = getEventTarget(nativeEvent);
240 - let targetInst = getClosestInstanceFromNode(nativeEventTarget);
246 + let targetInst = getClosestInstanceFromNode(targetNode);
247
248 if (targetInst !== null) {
249 const nearestMounted = getNearestMountedFiber(targetInst);
packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
+137 -2
@@ -23,15 +23,20 @@ import {
23 getContainerFromFiber,
24 getSuspenseInstanceFromFiber,
25 } from 'react-reconciler/src/ReactFiberTreeReflection';
26 -import {findInstanceBlockingEvent} from './ReactDOMEventListener';
26 +import {
27 + findInstanceBlockingEvent,
28 + findInstanceBlockingTarget,
29 +} from './ReactDOMEventListener';
30 import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent';
31 import {
32 getInstanceFromNode,
33 getClosestInstanceFromNode,
34 + getFiberCurrentPropsFromNode,
35 } from '../client/ReactDOMComponentTree';
36 import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags';
37 import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities';
38 import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration';
39 +import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin';
40
41 import {
42 attemptContinuousHydration,
@@ -41,6 +46,7 @@ import {
46 runWithPriority as attemptHydrationAtPriority,
47 getCurrentUpdatePriority,
48 } from 'react-reconciler/src/ReactEventPriorities';
49 +import {enableFormActions} from 'shared/ReactFeatureFlags';
50
51 // TODO: Upgrade this definition once we're on a newer version of Flow that
52 // has this definition built-in.
@@ -105,7 +111,7 @@ const discreteReplayableEvents: Array<DOMEventName> = [
111 'change',
112 'contextmenu',
113 'reset',
108 - 'submit',
114 + // 'submit', // stopPropagation blocks the replay mechanism
115 ];
116
117 export function isDiscreteEventThatRequiresHydration(
@@ -430,6 +436,67 @@ function scheduleCallbackIfUnblocked(
436 }
437 }
438
439 +type FormAction = FormData => void | Promise<void>;
440 +
441 +type FormReplayingQueue = Array<any>; // [form, submitter or action, formData...]
442 +
443 +let lastScheduledReplayQueue: null | FormReplayingQueue = null;
444 +
445 +function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) {
446 + if (lastScheduledReplayQueue === formReplayingQueue) {
447 + lastScheduledReplayQueue = null;
448 + }
449 + for (let i = 0; i < formReplayingQueue.length; i += 3) {
450 + const form: HTMLFormElement = formReplayingQueue[i];
451 + const submitterOrAction:
452 + | null
453 + | HTMLInputElement
454 + | HTMLButtonElement
455 + | FormAction = formReplayingQueue[i + 1];
456 + const formData: FormData = formReplayingQueue[i + 2];
457 + if (typeof submitterOrAction !== 'function') {
458 + // This action is not hydrated yet. This might be because it's blocked on
459 + // a different React instance or higher up our tree.
460 + const blockedOn = findInstanceBlockingTarget(submitterOrAction || form);
461 + if (blockedOn === null) {
462 + // We're not blocked but we don't have an action. This must mean that
463 + // this is in another React instance. We'll just skip past it.
464 + continue;
465 + } else {
466 + // We're blocked on something in this React instance. We'll retry later.
467 + break;
468 + }
469 + }
470 + const formInst = getInstanceFromNode(form);
471 + if (formInst !== null) {
472 + // This is part of our instance.
473 + // We're ready to replay this. Let's delete it from the queue.
474 + formReplayingQueue.splice(i, 3);
475 + i -= 3;
476 + dispatchReplayedFormAction(formInst, submitterOrAction, formData);
477 + // Continue without incrementing the index.
478 + continue;
479 + }
480 + // This form must've been part of a different React instance.
481 + // If we want to preserve ordering between React instances on the same root
482 + // we'd need some way for the other instance to ping us when it's done.
483 + // We'll just skip this and let the other instance execute it.
484 + }
485 +}
486 +
487 +function scheduleReplayQueueIfNeeded(formReplayingQueue: FormReplayingQueue) {
488 + // Schedule a callback to execute any unblocked form actions in.
489 + // We only keep track of the last queue which means that if multiple React oscillate
490 + // commits, we could schedule more callbacks than necessary but it's not a big deal
491 + // and we only really except one instance.
492 + if (lastScheduledReplayQueue !== formReplayingQueue) {
493 + lastScheduledReplayQueue = formReplayingQueue;
494 + scheduleCallback(NormalPriority, () =>
495 + replayUnblockedFormActions(formReplayingQueue),
496 + );
497 + }
498 +}
499 +
500 export function retryIfBlockedOn(
501 unblocked: Container | SuspenseInstance,
502 ): void {
@@ -467,4 +534,72 @@ export function retryIfBlockedOn(
534 }
535 }
536 }
537 +
538 + if (enableFormActions) {
539 + // Check the document if there are any queued form actions.
540 + const root = unblocked.getRootNode();
541 + const formReplayingQueue: void | FormReplayingQueue = (root: any)
542 + .$$reactFormReplay;
543 + if (formReplayingQueue != null) {
544 + for (let i = 0; i < formReplayingQueue.length; i += 3) {
545 + const form: HTMLFormElement = formReplayingQueue[i];
546 + const submitterOrAction:
547 + | null
548 + | HTMLInputElement
549 + | HTMLButtonElement
550 + | FormAction = formReplayingQueue[i + 1];
551 + const formProps = getFiberCurrentPropsFromNode(form);
552 + if (typeof submitterOrAction === 'function') {
553 + // This action has already resolved. We're just waiting to dispatch it.
554 + if (!formProps) {
555 + // This was not part of this React instance. It might have been recently
556 + // unblocking us from dispatching our events. So let's make sure we schedule
557 + // a retry.
558 + scheduleReplayQueueIfNeeded(formReplayingQueue);
559 + }
560 + continue;
561 + }
562 + let target: Node = form;
563 + if (formProps) {
564 + // This form belongs to this React instance but the submitter might
565 + // not be done yet.
566 + let action: null | FormAction = null;
567 + const submitter = submitterOrAction;
568 + if (submitter && submitter.hasAttribute('formAction')) {
569 + // The submitter is the one that is responsible for the action.
570 + target = submitter;
571 + const submitterProps = getFiberCurrentPropsFromNode(submitter);
572 + if (submitterProps) {
573 + // The submitter is part of this instance.
574 + action = (submitterProps: any).formAction;
575 + } else {
576 + const blockedOn = findInstanceBlockingTarget(target);
577 + if (blockedOn !== null) {
578 + // The submitter is not hydrated yet. We'll wait for it.
579 + continue;
580 + }
581 + // The submitter must have been a part of a different React instance.
582 + // Except the form isn't. We don't dispatch actions in this scenario.
583 + }
584 + } else {
585 + action = (formProps: any).action;
586 + }
587 + if (typeof action === 'function') {
588 + formReplayingQueue[i + 1] = action;
589 + } else {
590 + // Something went wrong so let's just delete this action.
591 + formReplayingQueue.splice(i, 3);
592 + i -= 3;
593 + }
594 + // Schedule a replay in case this unblocked something.
595 + scheduleReplayQueueIfNeeded(formReplayingQueue);
596 + continue;
597 + }
598 + // Something above this target is still blocked so we can't continue yet.
599 + // We're not sure if this target is actually part of this React instance
600 + // yet. It could be a different React as a child but at least some parent is.
601 + // We must continue for any further queued actions.
602 + }
603 + }
604 + }
605 }
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
+8
@@ -114,3 +114,11 @@ function extractEvents(
114 }
115
116 export {extractEvents};
117 +
118 +export function dispatchReplayedFormAction(
119 + formInst: Fiber,
120 + action: FormData => void | Promise<void>,
121 + formData: FormData,
122 +): void {
123 + startHostTransition(formInst, action, formData);
124 +}
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+53 -12
@@ -65,6 +65,7 @@ import {
65 completeBoundary as completeBoundaryFunction,
66 completeBoundaryWithStyles as styleInsertionFunction,
67 completeSegment as completeSegmentFunction,
68 + formReplaying as formReplayingRuntime,
69 } from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings';
70
71 import {
@@ -104,11 +105,12 @@ const ScriptStreamingFormat: StreamingFormat = 0;
105 const DataStreamingFormat: StreamingFormat = 1;
106
107 export type InstructionState = number;
107 -const NothingSent /* */ = 0b0000;
108 -const SentCompleteSegmentFunction /* */ = 0b0001;
109 -const SentCompleteBoundaryFunction /* */ = 0b0010;
110 -const SentClientRenderFunction /* */ = 0b0100;
111 -const SentStyleInsertionFunction /* */ = 0b1000;
108 +const NothingSent /* */ = 0b00000;
109 +const SentCompleteSegmentFunction /* */ = 0b00001;
110 +const SentCompleteBoundaryFunction /* */ = 0b00010;
111 +const SentClientRenderFunction /* */ = 0b00100;
112 +const SentStyleInsertionFunction /* */ = 0b01000;
113 +const SentFormReplayingRuntime /* */ = 0b10000;
114
115 // Per response, global state that is not contextual to the rendering subtree.
116 export type ResponseState = {
@@ -637,6 +639,7 @@ const actionJavaScriptURL = stringToPrecomputedChunk(
639
640 function pushFormActionAttribute(
641 target: Array<Chunk | PrecomputedChunk>,
642 + responseState: ResponseState,
643 formAction: any,
644 formEncType: any,
645 formMethod: any,
@@ -683,6 +686,7 @@ function pushFormActionAttribute(
686 actionJavaScriptURL,
687 attributeEnd,
688 );
689 + injectFormReplayingRuntime(responseState);
690 } else {
691 // Plain form actions support all the properties, so we have to emit them.
692 if (name !== null) {
@@ -1256,9 +1260,30 @@ function pushStartOption(
1260 return children;
1261 }
1262
1263 +const formReplayingRuntimeScript =
1264 + stringToPrecomputedChunk(formReplayingRuntime);
1265 +
1266 +function injectFormReplayingRuntime(responseState: ResponseState): void {
1267 + // If we haven't sent it yet, inject the runtime that tracks submitted JS actions
1268 + // for later replaying by Fiber. If we use an external runtime, we don't need
1269 + // to emit anything. It's always used.
1270 + if (
1271 + (responseState.instructions & SentFormReplayingRuntime) === NothingSent &&
1272 + (!enableFizzExternalRuntime || !responseState.externalRuntimeConfig)
1273 + ) {
1274 + responseState.instructions |= SentFormReplayingRuntime;
1275 + responseState.bootstrapChunks.unshift(
1276 + responseState.startInlineScript,
1277 + formReplayingRuntimeScript,
1278 + endInlineScript,
1279 + );
1280 + }
1281 +}
1282 +
1283 function pushStartForm(
1284 target: Array<Chunk | PrecomputedChunk>,
1285 props: Object,
1286 + responseState: ResponseState,
1287 ): ReactNodeList {
1288 target.push(startChunkForTag('form'));
1289
@@ -1335,6 +1360,7 @@ function pushStartForm(
1360 actionJavaScriptURL,
1361 attributeEnd,
1362 );
1363 + injectFormReplayingRuntime(responseState);
1364 } else {
1365 // Plain form actions support all the properties, so we have to emit them.
1366 if (formAction !== null) {
@@ -1365,6 +1391,7 @@ function pushStartForm(
1391 function pushInput(
1392 target: Array<Chunk | PrecomputedChunk>,
1393 props: Object,
1394 + responseState: ResponseState,
1395 ): ReactNodeList {
1396 if (__DEV__) {
1397 checkControlledValueProps('input', props);
@@ -1445,6 +1472,7 @@ function pushInput(
1472
1473 pushFormActionAttribute(
1474 target,
1475 + responseState,
1476 formAction,
1477 formEncType,
1478 formMethod,
@@ -1499,6 +1527,7 @@ function pushInput(
1527 function pushStartButton(
1528 target: Array<Chunk | PrecomputedChunk>,
1529 props: Object,
1530 + responseState: ResponseState,
1531 ): ReactNodeList {
1532 target.push(startChunkForTag('button'));
1533
@@ -1561,6 +1590,7 @@ function pushStartButton(
1590
1591 pushFormActionAttribute(
1592 target,
1593 + responseState,
1594 formAction,
1595 formEncType,
1596 formMethod,
@@ -2947,11 +2977,11 @@ export function pushStartInstance(
2977 case 'textarea':
2978 return pushStartTextArea(target, props);
2979 case 'input':
2950 - return pushInput(target, props);
2980 + return pushInput(target, props, responseState);
2981 case 'button':
2952 - return pushStartButton(target, props);
2982 + return pushStartButton(target, props, responseState);
2983 case 'form':
2954 - return pushStartForm(target, props);
2984 + return pushStartForm(target, props, responseState);
2985 case 'menuitem':
2986 return pushStartMenuItem(target, props);
2987 case 'title':
@@ -3127,7 +3157,7 @@ export function pushEndInstance(
3157 target.push(endTag1, stringToChunk(type), endTag2);
3158 }
3159
3130 -export function writeCompletedRoot(
3160 +function writeBootstrap(
3161 destination: Destination,
3162 responseState: ResponseState,
3163 ): boolean {
@@ -3137,11 +3167,20 @@ export function writeCompletedRoot(
3167 writeChunk(destination, bootstrapChunks[i]);
3168 }
3169 if (i < bootstrapChunks.length) {
3140 - return writeChunkAndReturn(destination, bootstrapChunks[i]);
3170 + const lastChunk = bootstrapChunks[i];
3171 + bootstrapChunks.length = 0;
3172 + return writeChunkAndReturn(destination, lastChunk);
3173 }
3174 return true;
3175 }
3176
3177 +export function writeCompletedRoot(
3178 + destination: Destination,
3179 + responseState: ResponseState,
3180 +): boolean {
3181 + return writeBootstrap(destination, responseState);
3182 +}
3183 +
3184 // Structural Nodes
3185
3186 // A placeholder is a node inside a hidden partial tree that can be filled in later, but before
@@ -3599,11 +3638,13 @@ export function writeCompletedBoundaryInstruction(
3638 writeChunk(destination, completeBoundaryScript3b);
3639 }
3640 }
3641 + let writeMore;
3642 if (scriptFormat) {
3603 - return writeChunkAndReturn(destination, completeBoundaryScriptEnd);
3643 + writeMore = writeChunkAndReturn(destination, completeBoundaryScriptEnd);
3644 } else {
3605 - return writeChunkAndReturn(destination, completeBoundaryDataEnd);
3645 + writeMore = writeChunkAndReturn(destination, completeBoundaryDataEnd);
3646 }
3647 + return writeBootstrap(destination, responseState) && writeMore;
3648 }
3649
3650 const clientRenderScript1Full = stringToPrecomputedChunk(
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js new
+8
@@ -0,0 +1,8 @@
1 +import {listenToFormSubmissionsForReplaying} from './ReactDOMFizzInstructionSetShared';
2 +
3 +// TODO: Export a helper function that throws the error from javascript URLs instead.
4 +// We can do that here since we mess with globals anyway and we can guarantee it has loaded.
5 +// It makes less sense in the external runtime since it's async loaded and doesn't expose globals
6 +// so we might have to have two different URLs.
7 +
8 +listenToFormSubmissionsForReplaying();
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js
+7
@@ -6,8 +6,11 @@ import {
6 clientRenderBoundary,
7 completeBoundary,
8 completeSegment,
9 + listenToFormSubmissionsForReplaying,
10 } from './ReactDOMFizzInstructionSetShared';
11
12 +import {enableFormActions} from 'shared/ReactFeatureFlags';
13 +
14 export {clientRenderBoundary, completeBoundary, completeSegment};
15
16 const resourceMap = new Map();
@@ -136,3 +139,7 @@ export function completeBoundaryWithStyles(
139 ),
140 );
141 }
142 +
143 +if (enableFormActions) {
144 + listenToFormSubmissionsForReplaying();
145 +}
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+2
@@ -9,3 +9,5 @@ export const completeBoundaryWithStyles =
9 '$RM=new Map;\n$RR=function(r,t,w){for(var u=$RC,n=$RM,p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&n.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var f=w[b++];if(!f){k=!1;b=0;continue}var c=!1,m=0;var d=f[m++];if(a=n.get(d)){var e=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel="stylesheet";for(a.dataset.precedence=\nl=f[m++];e=f[m++];)a.setAttribute(e,f[m++]);e=a._p=new Promise(function(x,y){a.onload=x;a.onerror=y});n.set(d,a)}d=a.getAttribute("media");!e||"l"===e.s||d&&!matchMedia(d).matches||h.push(e);if(c)continue}else{a=v[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then(u.bind(null,r,t,""),u.bind(null,r,t,"Resource failed to load"))};';
10 export const completeSegment =
11 '$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};';
12 +export const formReplaying =
13 + 'addEventListener("submit",function(a){if(!a.defaultPrevented){var c=a.target,d=a.submitter,e=c.action,b=d;if(d){var f=d.getAttribute("formAction");null!=f&&(e=f,b=null)}"javascript:throw new Error(\'A React form was unexpectedly submitted.\')"===e&&(a.preventDefault(),b?(a=document.createElement("input"),a.name=b.name,a.value=b.value,b.parentNode.insertBefore(a,b),b=new FormData(c),a.parentNode.removeChild(a)):b=new FormData(c),a=c.getRootNode(),(a.$$reactFormReplay=a.$$reactFormReplay||[]).push(c,\nd,b))}});';
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+81
@@ -125,3 +125,84 @@ export function completeSegment(containerID, placeholderID) {
125 }
126 placeholderNode.parentNode.removeChild(placeholderNode);
127 }
128 +
129 +// This is the exact URL string we expect that Fizz renders if we provide a function action.
130 +// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense
131 +// as a shared module for that reason.
132 +const EXPECTED_FORM_ACTION_URL =
133 + // eslint-disable-next-line no-script-url
134 + "javascript:throw new Error('A React form was unexpectedly submitted.')";
135 +
136 +export function listenToFormSubmissionsForReplaying() {
137 + // A global replay queue ensures actions are replayed in order.
138 + // This event listener should be above the React one. That way when
139 + // we preventDefault in React's handling we also prevent this event
140 + // from queing it. Since React listens to the root and the top most
141 + // container you can use is the document, the window is fine.
142 + // eslint-disable-next-line no-restricted-globals
143 + addEventListener('submit', event => {
144 + if (event.defaultPrevented) {
145 + // We let earlier events to prevent the action from submitting.
146 + return;
147 + }
148 + const form = event.target;
149 + const submitter = event['submitter'];
150 + let action = form.action;
151 + let formDataSubmitter = submitter;
152 + if (submitter) {
153 + const submitterAction = submitter.getAttribute('formAction');
154 + if (submitterAction != null) {
155 + // The submitter overrides the action.
156 + action = submitterAction;
157 + // If the submitter overrides the action, and it passes the test below,
158 + // that means that it was a function action which conceptually has no name.
159 + // Therefore, we exclude the submitter from the formdata.
160 + formDataSubmitter = null;
161 + }
162 + }
163 + if (action !== EXPECTED_FORM_ACTION_URL) {
164 + // The form is a regular form action, we can bail.
165 + return;
166 + }
167 +
168 + // Prevent native navigation.
169 + // This will also prevent other React's on the same page from listening.
170 + event.preventDefault();
171 +
172 + // Take a snapshot of the FormData at the time of the event.
173 + let formData;
174 + if (formDataSubmitter) {
175 + // The submitter's value should be included in the FormData.
176 + // It should be in the document order in the form.
177 + // Since the FormData constructor invokes the formdata event it also
178 + // needs to be available before that happens so after construction it's too
179 + // late. We use a temporary fake node for the duration of this event.
180 + // TODO: FormData takes a second argument that it's the submitter but this
181 + // is fairly new so not all browsers support it yet. Switch to that technique
182 + // when available.
183 + const temp = document.createElement('input');
184 + temp.name = formDataSubmitter.name;
185 + temp.value = formDataSubmitter.value;
186 + formDataSubmitter.parentNode.insertBefore(temp, formDataSubmitter);
187 + formData = new FormData(form);
188 + temp.parentNode.removeChild(temp);
189 + } else {
190 + formData = new FormData(form);
191 + }
192 +
193 + // Queue for replaying later. This field could potentially be shared with multiple
194 + // Reacts on the same page since each one will preventDefault for the next one.
195 + // This means that this protocol is shared with any React version that shares the same
196 + // javascript: URL placeholder value. So we might not be the first to declare it.
197 + // We attach it to the form's root node, which is the shared environment context
198 + // where we preserve sequencing and where we'll pick it up from during hydration.
199 + // In practice, this is just the same as document but we might support shadow trees
200 + // in the future.
201 + const root = form.getRootNode();
202 + (root['$$reactFormReplay'] = root['$$reactFormReplay'] || []).push(
203 + form,
204 + submitter,
205 + formData,
206 + );
207 + });
208 +}
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+76 -1
@@ -9,6 +9,8 @@
9
10 'use strict';
11
12 +import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils';
13 +
14 // Polyfills for test environment
15 global.ReadableStream =
16 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
@@ -65,7 +67,9 @@ describe('ReactDOMFizzForm', () => {
67 }
68 result += Buffer.from(value).toString('utf8');
69 }
68 - container.innerHTML = result;
70 + const temp = document.createElement('div');
71 + temp.innerHTML = result;
72 + insertNodesAndExecuteScripts(temp, container, null);
73 }
74
75 // @gate enableFormActions
@@ -378,4 +382,75 @@ describe('ReactDOMFizzForm', () => {
382 await act(() => ReactDOMClient.hydrateRoot(container, <App />));
383 expect(container.textContent).toBe('Pending: false');
384 });
385 +
386 + // @gate enableFormActions
387 + it('should replay a form action after hydration', async () => {
388 + let foo;
389 + function action(formData) {
390 + foo = formData.get('foo');
391 + }
392 + function App() {
393 + return (
394 + <form action={action}>
395 + <input type="text" name="foo" defaultValue="bar" />
396 + </form>
397 + );
398 + }
399 +
400 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
401 + await readIntoContainer(stream);
402 +
403 + // Dispatch an event before hydration
404 + submit(container.getElementsByTagName('form')[0]);
405 +
406 + await act(async () => {
407 + ReactDOMClient.hydrateRoot(container, <App />);
408 + });
409 +
410 + // It should've now been replayed
411 + expect(foo).toBe('bar');
412 + });
413 +
414 + // @gate enableFormActions
415 + it('should replay input/button formAction', async () => {
416 + let rootActionCalled = false;
417 + let savedTitle = null;
418 + let deletedTitle = null;
419 +
420 + function action(formData) {
421 + rootActionCalled = true;
422 + }
423 +
424 + function saveItem(formData) {
425 + savedTitle = formData.get('title');
426 + }
427 +
428 + function deleteItem(formData) {
429 + deletedTitle = formData.get('title');
430 + }
431 +
432 + function App() {
433 + return (
434 + <form action={action}>
435 + <input type="text" name="title" defaultValue="Hello" />
436 + <input type="submit" formAction={saveItem} value="Save" />
437 + <button formAction={deleteItem}>Delete</button>
438 + </form>
439 + );
440 + }
441 +
442 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
443 + await readIntoContainer(stream);
444 +
445 + submit(container.getElementsByTagName('input')[1]);
446 + submit(container.getElementsByTagName('button')[0]);
447 +
448 + await act(async () => {
449 + ReactDOMClient.hydrateRoot(container, <App />);
450 + });
451 +
452 + expect(savedTitle).toBe('Hello');
453 + expect(deletedTitle).toBe('Hello');
454 + expect(rootActionCalled).toBe(false);
455 + });
456 });
scripts/rollup/externs/closure-externs.js new
+9
@@ -0,0 +1,9 @@
1 +/**
2 + * @externs
3 + */
4 +/* eslint-disable */
5 +
6 +'use strict';
7 +
8 +/** @type {function} */
9 +var addEventListener;
scripts/rollup/generate-inline-fizz-runtime.js
+5
@@ -29,6 +29,10 @@ const config = [
29 entry: 'ReactDOMFizzInlineCompleteSegment.js',
30 exportName: 'completeSegment',
31 },
32 + {
33 + entry: 'ReactDOMFizzInlineFormReplaying.js',
34 + exportName: 'formReplaying',
35 + },
36 ];
37
38 const prettierConfig = require('../../.prettierrc.js');
@@ -40,6 +44,7 @@ async function main() {
44 const compiler = new ClosureCompiler({
45 entry_point: fullEntryPath,
46 js: [
47 + require.resolve('./externs/closure-externs.js'),
48 fullEntryPath,
49 instructionDir + '/ReactDOMFizzInstructionSetInlineSource.js',
50 instructionDir + '/ReactDOMFizzInstructionSetShared.js',