main
js 665 lines 26.5 KB
Raw
1 /* eslint-disable dot-notation */
2
3 // Shared implementation and constants between the inline script and external
4 // runtime instruction sets.
5
6 const ELEMENT_NODE = 1;
7 const COMMENT_NODE = 8;
8 const ACTIVITY_START_DATA = '&';
9 const ACTIVITY_END_DATA = '/&';
10 const SUSPENSE_START_DATA = '$';
11 const SUSPENSE_END_DATA = '/$';
12 const SUSPENSE_PENDING_START_DATA = '$?';
13 const SUSPENSE_QUEUED_START_DATA = '$~';
14 const SUSPENSE_FALLBACK_START_DATA = '$!';
15
16 const FALLBACK_THROTTLE_MS = 300;
17
18 const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500;
19
20 // If you have a target goal in mind for a metric to hit, you don't want the
21 // only reason you miss it by a little bit to be throttling heuristics.
22 // This tries to avoid throttling if avoiding it would let you hit this metric.
23 // This is derived from trying to hit an LCP of 2.5 seconds with some head room.
24 const TARGET_VANITY_METRIC = 2300;
25
26 // TODO: Symbols that are referenced outside this module use dynamic accessor
27 // notation instead of dot notation to prevent Closure's advanced compilation
28 // mode from renaming. We could use extern files instead, but I couldn't get it
29 // working. Closure converts it to a dot access anyway, though, so it's not an
30 // urgent issue.
31
32 export function revealCompletedBoundaries(batch) {
33 window['$RT'] = performance.now();
34 for (let i = 0; i < batch.length; i += 2) {
35 const suspenseIdNode = batch[i];
36 const contentNode = batch[i + 1];
37 if (contentNode.parentNode === null) {
38 // If the client has failed hydration we may have already deleted the streaming
39 // segments. The server may also have emitted a complete instruction but cancelled
40 // the segment. Regardless we can ignore this case.
41 } else {
42 // We can detach the content now.
43 // Completions of boundaries within this contentNode will now find the boundary
44 // in its designated place.
45 contentNode.parentNode.removeChild(contentNode);
46 }
47 // Clear all the existing children. This is complicated because
48 // there can be embedded Suspense boundaries in the fallback.
49 // This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
50 // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
51 // They never hydrate anyway. However, currently we support incrementally loading the fallback.
52 const parentInstance = suspenseIdNode.parentNode;
53 if (!parentInstance) {
54 // We may have client-rendered this boundary already. Skip it.
55 continue;
56 }
57
58 // Find the boundary around the fallback. This is always the previous node.
59 const suspenseNode = suspenseIdNode.previousSibling;
60
61 let node = suspenseIdNode;
62 let depth = 0;
63 do {
64 if (node && node.nodeType === COMMENT_NODE) {
65 const data = node.data;
66 if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
67 if (depth === 0) {
68 break;
69 } else {
70 depth--;
71 }
72 } else if (
73 data === SUSPENSE_START_DATA ||
74 data === SUSPENSE_PENDING_START_DATA ||
75 data === SUSPENSE_QUEUED_START_DATA ||
76 data === SUSPENSE_FALLBACK_START_DATA ||
77 data === ACTIVITY_START_DATA
78 ) {
79 depth++;
80 }
81 }
82
83 const nextNode = node.nextSibling;
84 parentInstance.removeChild(node);
85 node = nextNode;
86 } while (node);
87
88 const endOfBoundary = node;
89
90 // Insert all the children from the contentNode between the start and end of suspense boundary.
91 while (contentNode.firstChild) {
92 parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
93 }
94
95 suspenseNode.data = SUSPENSE_START_DATA;
96 if (suspenseNode['_reactRetry']) {
97 requestAnimationFrame(suspenseNode['_reactRetry']);
98 }
99 }
100 batch.length = 0;
101 }
102
103 export function revealCompletedBoundariesWithViewTransitions(
104 revealBoundaries,
105 batch,
106 ) {
107 let shouldStartViewTransition = false;
108 let autoNameIdx = 0;
109 const restoreQueue = [];
110 function applyViewTransitionName(element, classAttributeName) {
111 const className = element.getAttribute(classAttributeName);
112 if (!className) {
113 return;
114 }
115 // Add any elements we apply a name to a queue to be reverted when we start.
116 const elementStyle = element.style;
117 restoreQueue.push(
118 element,
119 elementStyle['viewTransitionName'],
120 elementStyle['viewTransitionClass'],
121 );
122 if (className !== 'auto') {
123 elementStyle['viewTransitionClass'] = className;
124 }
125 let name = element.getAttribute('vt-name');
126 if (!name) {
127 // Auto-generate a name for this one.
128 // TODO: We don't have a prefix to pick from here but maybe we don't need it
129 // since it's only applicable temporarily during this specific animation.
130 const idPrefix = '';
131 name = '_' + idPrefix + 'T_' + autoNameIdx++ + '_';
132 }
133 // If the name isn't valid CSS identifier, base64 encode the name instead.
134 // This doesn't let you select it in custom CSS selectors but it does work in current
135 // browsers.
136 const escapedName =
137 CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name;
138 elementStyle['viewTransitionName'] = escapedName;
139 shouldStartViewTransition = true;
140 }
141 try {
142 const existingTransition = document['__reactViewTransition'];
143 if (existingTransition) {
144 // Retry after the previous ViewTransition finishes.
145 existingTransition.finished.finally(window['$RV'].bind(null, batch));
146 return;
147 }
148 // First collect all entering names that might form pairs exiting names.
149 const appearingViewTransitions = new Map();
150 for (let i = 1; i < batch.length; i += 2) {
151 const contentNode = batch[i];
152 const appearingElements = contentNode.querySelectorAll('[vt-share]');
153 for (let j = 0; j < appearingElements.length; j++) {
154 const appearingElement = appearingElements[j];
155 appearingViewTransitions.set(
156 appearingElement.getAttribute('vt-name'),
157 appearingElement,
158 );
159 }
160 }
161 const suspenseyImages = [];
162 // Next we'll find the nodes that we're going to animate and apply names to them..
163 for (let i = 0; i < batch.length; i += 2) {
164 const suspenseIdNode = batch[i];
165 const parentInstance = suspenseIdNode.parentNode;
166 if (!parentInstance) {
167 // We may have client-rendered this boundary already. Skip it.
168 continue;
169 }
170 const parentRect = parentInstance.getBoundingClientRect();
171 if (
172 !parentRect.left &&
173 !parentRect.top &&
174 !parentRect.width &&
175 !parentRect.height
176 ) {
177 // If the parent instance is display: none then we don't animate this boundary.
178 // This can happen when this boundary is actually a child of a different boundary that
179 // isn't yet revealed or is about to be revealed, but in that case that boundary
180 // should do the exit/enter and not this one. Conveniently this also lets us skip
181 // this if it's just in a hidden tree in general.
182 // TODO: Should we skip it if it's out of viewport? It's possible that it gets
183 // brought into the viewport by changing size.
184 // TODO: There's a another case where an inner boundary is inside a fallback that
185 // is about to be deleted. In that case we should not run exit animations on the inner.
186 continue;
187 }
188
189 // Apply exit animations to the immediate elements inside the fallback.
190 let node = suspenseIdNode;
191 let depth = 0;
192 while (node) {
193 if (node.nodeType === COMMENT_NODE) {
194 const data = node.data;
195 if (data === SUSPENSE_END_DATA) {
196 if (depth === 0) {
197 break;
198 } else {
199 depth--;
200 }
201 } else if (
202 data === SUSPENSE_START_DATA ||
203 data === SUSPENSE_PENDING_START_DATA ||
204 data === SUSPENSE_QUEUED_START_DATA ||
205 data === SUSPENSE_FALLBACK_START_DATA
206 ) {
207 depth++;
208 }
209 } else if (node.nodeType === ELEMENT_NODE) {
210 const exitElement = node;
211 const exitName = exitElement.getAttribute('vt-name');
212 const pairedElement = appearingViewTransitions.get(exitName);
213 applyViewTransitionName(
214 exitElement,
215 pairedElement ? 'vt-share' : 'vt-exit',
216 );
217 if (pairedElement) {
218 // Activate the other side as well.
219 applyViewTransitionName(pairedElement, 'vt-share');
220 appearingViewTransitions.set(exitName, null); // mark claimed
221 }
222 // Next we'll look inside this element for pairs to trigger "share".
223 const disappearingElements =
224 exitElement.querySelectorAll('[vt-share]');
225 for (let j = 0; j < disappearingElements.length; j++) {
226 const disappearingElement = disappearingElements[j];
227 const name = disappearingElement.getAttribute('vt-name');
228 const appearingElement = appearingViewTransitions.get(name);
229 if (appearingElement) {
230 applyViewTransitionName(disappearingElement, 'vt-share');
231 applyViewTransitionName(appearingElement, 'vt-share');
232 appearingViewTransitions.set(name, null); // mark claimed
233 }
234 }
235 // Relay the exit to nested ViewTransitions that opted in
236 const relayExitElements =
237 exitElement.querySelectorAll('[vt-parent-exit]');
238 for (let j = 0; j < relayExitElements.length; j++) {
239 applyViewTransitionName(relayExitElements[j], 'vt-parent-exit');
240 }
241 }
242 node = node.nextSibling;
243 }
244
245 // Apply enter animations to the new nodes about to be inserted.
246 const contentNode = batch[i + 1];
247 let enterElement = contentNode.firstElementChild;
248 while (enterElement) {
249 const paired =
250 appearingViewTransitions.get(enterElement.getAttribute('vt-name')) ===
251 null;
252 if (!paired) {
253 applyViewTransitionName(enterElement, 'vt-enter');
254 }
255 // Relay the enter to nested ViewTransitions that opted in
256 const relayEnterElements =
257 enterElement.querySelectorAll('[vt-parent-enter]');
258 for (let j = 0; j < relayEnterElements.length; j++) {
259 applyViewTransitionName(relayEnterElements[j], 'vt-parent-enter');
260 }
261 enterElement = enterElement.nextElementSibling;
262 }
263
264 // Apply update animations to any parents and siblings that might be affected.
265 let ancestorElement = parentInstance;
266 do {
267 let childElement = ancestorElement.firstElementChild;
268 while (childElement) {
269 // TODO: Bail out if we can
270 const updateClassName = childElement.getAttribute('vt-update');
271 if (
272 updateClassName &&
273 updateClassName !== 'none' &&
274 !restoreQueue.includes(childElement)
275 ) {
276 // If we have already handled this element as part of another exit/enter/share, don't override.
277 applyViewTransitionName(childElement, 'vt-update');
278 }
279 childElement = childElement.nextElementSibling;
280 }
281 } while (
282 (ancestorElement = ancestorElement.parentNode) &&
283 ancestorElement.nodeType === ELEMENT_NODE &&
284 ancestorElement.getAttribute('vt-update') !== 'none'
285 );
286
287 // Find the appearing Suspensey Images inside the new content.
288 const appearingImages = contentNode.querySelectorAll(
289 'img[src]:not([loading="lazy"])',
290 );
291 // TODO: Consider marking shouldStartViewTransition if we found any images.
292 // But only once we can disable the root animation for that case.
293 suspenseyImages.push.apply(suspenseyImages, appearingImages);
294 }
295 if (shouldStartViewTransition) {
296 const transition = (document['__reactViewTransition'] = document[
297 'startViewTransition'
298 ]({
299 update: () => {
300 revealBoundaries(batch);
301 const blockingPromises = [
302 // Force layout to trigger font loading, we stash the actual value to trick minifiers.
303 document.documentElement.clientHeight,
304 // Block on fonts finishing loading before revealing these boundaries.
305 document.fonts.ready,
306 ];
307 for (let i = 0; i < suspenseyImages.length; i++) {
308 const suspenseyImage = suspenseyImages[i];
309 if (!suspenseyImage.complete) {
310 const rect = suspenseyImage.getBoundingClientRect();
311 const inViewport =
312 rect.bottom > 0 &&
313 rect.right > 0 &&
314 rect.top < window.innerHeight &&
315 rect.left < window.innerWidth;
316 if (inViewport) {
317 // TODO: Use decode() instead of the load event here once the fix in
318 // https://issues.chromium.org/issues/420748301 has propagated fully.
319 const loadingImage = new Promise(resolve => {
320 suspenseyImage.addEventListener('load', resolve);
321 suspenseyImage.addEventListener('error', resolve);
322 });
323 blockingPromises.push(loadingImage);
324 }
325 }
326 }
327 return Promise.race([
328 Promise.all(blockingPromises),
329 new Promise(resolve => {
330 const currentTime = performance.now();
331 const msUntilTimeout =
332 // If the throttle would make us miss the target metric, then shorten the throttle.
333 // performance.now()'s zero value is assumed to be the start time of the metric.
334 currentTime < TARGET_VANITY_METRIC &&
335 currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS
336 ? TARGET_VANITY_METRIC - currentTime
337 : // Otherwise it's throttled starting from last commit time.
338 SUSPENSEY_FONT_AND_IMAGE_TIMEOUT;
339 setTimeout(resolve, msUntilTimeout);
340 }),
341 ]);
342 },
343 types: [], // TODO: Add a hard coded type for Suspense reveals.
344 }));
345 transition.ready.finally(() => {
346 // Restore all the names/classes that we applied to what they were before.
347 // We do it in reverse order in case there were duplicates so the first one wins.
348 for (let i = restoreQueue.length - 3; i >= 0; i -= 3) {
349 const element = restoreQueue[i];
350 const elementStyle = element.style;
351 const previousName = restoreQueue[i + 1];
352 elementStyle['viewTransitionName'] = previousName;
353 const previousClassName = restoreQueue[i + 1];
354 elementStyle['viewTransitionClass'] = previousClassName;
355 if (element.getAttribute('style') === '') {
356 element.removeAttribute('style');
357 }
358 }
359 });
360 transition.finished.finally(() => {
361 if (document['__reactViewTransition'] === transition) {
362 document['__reactViewTransition'] = null;
363 }
364 });
365 // Queue any future completions into its own batch since they won't have been
366 // snapshotted by this one.
367 window['$RB'] = [];
368 return;
369 }
370 // Fall through to reveal.
371 } catch (x) {
372 // Fall through to reveal.
373 }
374 // ViewTransitions v2 not supported or no ViewTransitions found. Reveal immediately.
375 revealBoundaries(batch);
376 }
377
378 export function clientRenderBoundary(
379 suspenseBoundaryID,
380 errorDigest,
381 errorMsg,
382 errorStack,
383 errorComponentStack,
384 ) {
385 // Find the fallback's first element.
386 const suspenseIdNode = document.getElementById(suspenseBoundaryID);
387 if (!suspenseIdNode) {
388 // The user must have already navigated away from this tree.
389 // E.g. because the parent was hydrated.
390 return;
391 }
392 // Find the boundary around the fallback. This is always the previous node.
393 const suspenseNode = suspenseIdNode.previousSibling;
394 // Tag it to be client rendered.
395 suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
396 // assign error metadata to first sibling
397 const dataset = suspenseIdNode.dataset;
398 if (errorDigest != null) dataset['dgst'] = errorDigest;
399 if (errorMsg) dataset['msg'] = errorMsg;
400 if (errorStack) dataset['stck'] = errorStack;
401 if (errorComponentStack) dataset['cstck'] = errorComponentStack;
402 // Tell React to retry it if the parent already hydrated.
403 if (suspenseNode['_reactRetry']) {
404 suspenseNode['_reactRetry']();
405 }
406 }
407
408 export function completeBoundary(suspenseBoundaryID, contentID) {
409 const contentNodeOuter = document.getElementById(contentID);
410 if (!contentNodeOuter) {
411 // If the client has failed hydration we may have already deleted the streaming
412 // segments. The server may also have emitted a complete instruction but cancelled
413 // the segment. Regardless we can ignore this case.
414 return;
415 }
416
417 // Find the fallback's first element.
418 const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
419 if (!suspenseIdNodeOuter) {
420 // We'll never reveal this boundary so we can remove its content immediately.
421 // Otherwise we'll leave it in until we reveal it.
422 // This is important in case this specific boundary contains other boundaries
423 // that may get completed before we reveal this one.
424 contentNodeOuter.parentNode.removeChild(contentNodeOuter);
425
426 // The user must have already navigated away from this tree.
427 // E.g. because the parent was hydrated. That's fine there's nothing to do
428 // but we have to make sure that we already deleted the container node.
429 return;
430 }
431
432 // Mark this Suspense boundary as queued so we know not to client render it
433 // at the end of document load.
434 const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
435 suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
436 // Queue this boundary for the next batch
437 window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
438
439 if (window['$RB'].length === 2) {
440 // This is the first time we've pushed to the batch. We need to schedule a callback
441 // to flush the batch. This is delayed by the throttle heuristic.
442 if (typeof window['$RT'] !== 'number') {
443 // If we haven't had our rAF callback yet, schedule everything for the first paint.
444 requestAnimationFrame(window['$RV'].bind(null, window['$RB']));
445 } else {
446 const currentTime = performance.now();
447 const msUntilTimeout =
448 // If the throttle would make us miss the target metric, then shorten the throttle.
449 // performance.now()'s zero value is assumed to be the start time of the metric.
450 currentTime < TARGET_VANITY_METRIC &&
451 currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS
452 ? TARGET_VANITY_METRIC - currentTime
453 : // Otherwise it's throttled starting from last commit time.
454 window['$RT'] + FALLBACK_THROTTLE_MS - currentTime;
455 // We always schedule the flush in a timer even if it's very low or negative to allow
456 // for multiple completeBoundary calls that are already queued to have a chance to
457 // make the batch.
458 setTimeout(window['$RV'].bind(null, window['$RB']), msUntilTimeout);
459 }
460 }
461 }
462
463 export function completeBoundaryWithStyles(
464 suspenseBoundaryID,
465 contentID,
466 stylesheetDescriptors,
467 ) {
468 const precedences = new Map();
469 const thisDocument = document;
470 let lastResource, node;
471
472 // Seed the precedence list with existing resources and collect hoistable style tags
473 const nodes = thisDocument.querySelectorAll(
474 'link[data-precedence],style[data-precedence]',
475 );
476 const styleTagsToHoist = [];
477 for (let i = 0; (node = nodes[i++]); ) {
478 if (node.getAttribute('media') === 'not all') {
479 styleTagsToHoist.push(node);
480 } else {
481 if (node.tagName === 'LINK') {
482 window['$RM'].set(node.getAttribute('href'), node);
483 }
484 precedences.set(node.dataset['precedence'], (lastResource = node));
485 }
486 }
487
488 let i = 0;
489 const dependencies = [];
490 let href, precedence, attr, loadingState, resourceEl, media;
491
492 function cleanupWith(cb) {
493 this['_p'] = null;
494 cb();
495 }
496
497 // Sheets Mode
498 let sheetMode = true;
499 while (true) {
500 if (sheetMode) {
501 // Sheet Mode iterates over the stylesheet arguments and constructs them if new or checks them for
502 // dependency if they already existed
503 const stylesheetDescriptor = stylesheetDescriptors[i++];
504 if (!stylesheetDescriptor) {
505 // enter <style> Mode
506 sheetMode = false;
507 i = 0;
508 continue;
509 }
510
511 let avoidInsert = false;
512 let j = 0;
513 href = stylesheetDescriptor[j++];
514
515 if ((resourceEl = window['$RM'].get(href))) {
516 // We have an already inserted stylesheet.
517 loadingState = resourceEl['_p'];
518 avoidInsert = true;
519 } else {
520 // We haven't already processed this href so we need to construct a stylesheet and hoist it
521 // We construct it here and attach a loadingState. We also check whether it matches
522 // media before we include it in the dependency array.
523 resourceEl = thisDocument.createElement('link');
524 resourceEl.href = href;
525 resourceEl.rel = 'stylesheet';
526 resourceEl.dataset['precedence'] = precedence =
527 stylesheetDescriptor[j++];
528 while ((attr = stylesheetDescriptor[j++])) {
529 resourceEl.setAttribute(attr, stylesheetDescriptor[j++]);
530 }
531 loadingState = resourceEl['_p'] = new Promise((resolve, reject) => {
532 resourceEl.onload = cleanupWith.bind(resourceEl, resolve);
533 resourceEl.onerror = cleanupWith.bind(resourceEl, reject);
534 });
535 // Save this resource element so we can bailout if it is used again
536 window['$RM'].set(href, resourceEl);
537 }
538 media = resourceEl.getAttribute('media');
539 if (loadingState && (!media || window['matchMedia'](media).matches)) {
540 dependencies.push(loadingState);
541 }
542 if (avoidInsert) {
543 // We have a link that is already in the document. We don't want to fall through to the insert path
544 continue;
545 }
546 } else {
547 // <style> mode iterates over not-yet-hoisted <style> tags with data-precedence and hoists them.
548 resourceEl = styleTagsToHoist[i++];
549 if (!resourceEl) {
550 // we are done with all style tags
551 break;
552 }
553
554 precedence = resourceEl.getAttribute('data-precedence');
555 resourceEl.removeAttribute('media');
556 }
557
558 // resourceEl is either a newly constructed <link rel="stylesheet" ...> or a <style> tag requiring hoisting
559 const prior = precedences.get(precedence) || lastResource;
560 if (prior === lastResource) {
561 lastResource = resourceEl;
562 }
563 precedences.set(precedence, resourceEl);
564
565 // Finally, we insert the newly constructed instance at an appropriate location
566 // in the Document.
567 if (prior) {
568 prior.parentNode.insertBefore(resourceEl, prior.nextSibling);
569 } else {
570 const head = thisDocument.head;
571 head.insertBefore(resourceEl, head.firstChild);
572 }
573 }
574
575 const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
576 if (suspenseIdNodeOuter) {
577 // Mark this Suspense boundary as queued so we know not to client render it
578 // at the end of document load.
579 const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
580 suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
581 }
582
583 Promise.all(dependencies).then(
584 window['$RC'].bind(null, suspenseBoundaryID, contentID),
585 window['$RX'].bind(null, suspenseBoundaryID, 'CSS failed to load'),
586 );
587 }
588
589 export function completeSegment(containerID, placeholderID) {
590 const segmentContainer = document.getElementById(containerID);
591 const placeholderNode = document.getElementById(placeholderID);
592 // We always expect both nodes to exist here because, while we might
593 // have navigated away from the main tree, we still expect the detached
594 // tree to exist.
595 segmentContainer.parentNode.removeChild(segmentContainer);
596 while (segmentContainer.firstChild) {
597 placeholderNode.parentNode.insertBefore(
598 segmentContainer.firstChild,
599 placeholderNode,
600 );
601 }
602 placeholderNode.parentNode.removeChild(placeholderNode);
603 }
604
605 // This is the exact URL string we expect that Fizz renders if we provide a function action.
606 // We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense
607 // as a shared module for that reason.
608 const EXPECTED_FORM_ACTION_URL =
609 // eslint-disable-next-line no-script-url
610 "javascript:throw new Error('React form unexpectedly submitted.')";
611
612 export function listenToFormSubmissionsForReplaying() {
613 // A global replay queue ensures actions are replayed in order.
614 // This event listener should be above the React one. That way when
615 // we preventDefault in React's handling we also prevent this event
616 // from queing it. Since React listens to the root and the top most
617 // container you can use is the document, the window is fine.
618 // eslint-disable-next-line no-restricted-globals
619 addEventListener('submit', event => {
620 if (event.defaultPrevented) {
621 // We let earlier events to prevent the action from submitting.
622 return;
623 }
624 const form = event.target;
625 const submitter = event['submitter'];
626 let action = form.action;
627 let formDataSubmitter = submitter;
628 if (submitter) {
629 const submitterAction = submitter.getAttribute('formAction');
630 if (submitterAction != null) {
631 // The submitter overrides the action.
632 action = submitterAction;
633 // If the submitter overrides the action, and it passes the test below,
634 // that means that it was a function action which conceptually has no name.
635 // Therefore, we exclude the submitter from the formdata.
636 formDataSubmitter = null;
637 }
638 }
639 if (action !== EXPECTED_FORM_ACTION_URL) {
640 // The form is a regular form action, we can bail.
641 return;
642 }
643
644 // Prevent native navigation.
645 // This will also prevent other React's on the same page from listening.
646 event.preventDefault();
647
648 // Take a snapshot of the FormData at the time of the event.
649 const formData = new FormData(form, formDataSubmitter);
650
651 // Queue for replaying later. This field could potentially be shared with multiple
652 // Reacts on the same page since each one will preventDefault for the next one.
653 // This means that this protocol is shared with any React version that shares the same
654 // javascript: URL placeholder value. So we might not be the first to declare it.
655 // We attach it to the form's root node, which is the shared environment context
656 // where we preserve sequencing and where we'll pick it up from during hydration.
657 // If there's no ownerDocument, then this is the document.
658 const root = form.ownerDocument || form;
659 (root['$$reactFormReplay'] = root['$$reactFormReplay'] || []).push(
660 form,
661 submitter,
662 formData,
663 );
664 });
665 }