From 657f8742c5620600274a634a0ed054a65b825e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 25 Apr 2023 10:22:20 -0400 Subject: [PATCH] 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. --- .../src/events/ReactDOMEventListener.js | 14 +- .../src/events/ReactDOMEventReplaying.js | 139 +++++++++++++++++- .../events/plugins/FormActionEventPlugin.js | 8 + .../src/server/ReactFizzConfigDOM.js | 65 ++++++-- .../ReactDOMFizzInlineFormReplaying.js | 8 + ...actDOMFizzInstructionSetExternalRuntime.js | 7 + ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 + .../ReactDOMFizzInstructionSetShared.js | 81 ++++++++++ .../src/__tests__/ReactDOMFizzForm-test.js | 77 +++++++++- scripts/rollup/externs/closure-externs.js | 9 ++ .../rollup/generate-inline-fizz-runtime.js | 5 + 11 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js create mode 100644 scripts/rollup/externs/closure-externs.js diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js index 70cec7fa0e608..237cde231176b 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js @@ -225,19 +225,25 @@ export function dispatchEvent( ); } +export function findInstanceBlockingEvent( + nativeEvent: AnyNativeEvent, +): null | Container | SuspenseInstance { + const nativeEventTarget = getEventTarget(nativeEvent); + return findInstanceBlockingTarget(nativeEventTarget); +} + export let return_targetInst: null | Fiber = null; // Returns a SuspenseInstance or Container if it's blocked. // The return_targetInst field above is conceptually part of the return value. -export function findInstanceBlockingEvent( - nativeEvent: AnyNativeEvent, +export function findInstanceBlockingTarget( + targetNode: Node, ): null | Container | SuspenseInstance { // TODO: Warn if _enabled is false. return_targetInst = null; - const nativeEventTarget = getEventTarget(nativeEvent); - let targetInst = getClosestInstanceFromNode(nativeEventTarget); + let targetInst = getClosestInstanceFromNode(targetNode); if (targetInst !== null) { const nearestMounted = getNearestMountedFiber(targetInst); diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index 59fc3a8a61811..7960e6eced30d 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -23,15 +23,20 @@ import { getContainerFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; -import {findInstanceBlockingEvent} from './ReactDOMEventListener'; +import { + findInstanceBlockingEvent, + findInstanceBlockingTarget, +} from './ReactDOMEventListener'; import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent'; import { getInstanceFromNode, getClosestInstanceFromNode, + getFiberCurrentPropsFromNode, } from '../client/ReactDOMComponentTree'; import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities'; import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration'; +import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin'; import { attemptContinuousHydration, @@ -41,6 +46,7 @@ import { runWithPriority as attemptHydrationAtPriority, getCurrentUpdatePriority, } from 'react-reconciler/src/ReactEventPriorities'; +import {enableFormActions} from 'shared/ReactFeatureFlags'; // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. @@ -105,7 +111,7 @@ const discreteReplayableEvents: Array = [ 'change', 'contextmenu', 'reset', - 'submit', + // 'submit', // stopPropagation blocks the replay mechanism ]; export function isDiscreteEventThatRequiresHydration( @@ -430,6 +436,67 @@ function scheduleCallbackIfUnblocked( } } +type FormAction = FormData => void | Promise; + +type FormReplayingQueue = Array; // [form, submitter or action, formData...] + +let lastScheduledReplayQueue: null | FormReplayingQueue = null; + +function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) { + if (lastScheduledReplayQueue === formReplayingQueue) { + lastScheduledReplayQueue = null; + } + for (let i = 0; i < formReplayingQueue.length; i += 3) { + const form: HTMLFormElement = formReplayingQueue[i]; + const submitterOrAction: + | null + | HTMLInputElement + | HTMLButtonElement + | FormAction = formReplayingQueue[i + 1]; + const formData: FormData = formReplayingQueue[i + 2]; + if (typeof submitterOrAction !== 'function') { + // This action is not hydrated yet. This might be because it's blocked on + // a different React instance or higher up our tree. + const blockedOn = findInstanceBlockingTarget(submitterOrAction || form); + if (blockedOn === null) { + // We're not blocked but we don't have an action. This must mean that + // this is in another React instance. We'll just skip past it. + continue; + } else { + // We're blocked on something in this React instance. We'll retry later. + break; + } + } + const formInst = getInstanceFromNode(form); + if (formInst !== null) { + // This is part of our instance. + // We're ready to replay this. Let's delete it from the queue. + formReplayingQueue.splice(i, 3); + i -= 3; + dispatchReplayedFormAction(formInst, submitterOrAction, formData); + // Continue without incrementing the index. + continue; + } + // This form must've been part of a different React instance. + // If we want to preserve ordering between React instances on the same root + // we'd need some way for the other instance to ping us when it's done. + // We'll just skip this and let the other instance execute it. + } +} + +function scheduleReplayQueueIfNeeded(formReplayingQueue: FormReplayingQueue) { + // Schedule a callback to execute any unblocked form actions in. + // We only keep track of the last queue which means that if multiple React oscillate + // commits, we could schedule more callbacks than necessary but it's not a big deal + // and we only really except one instance. + if (lastScheduledReplayQueue !== formReplayingQueue) { + lastScheduledReplayQueue = formReplayingQueue; + scheduleCallback(NormalPriority, () => + replayUnblockedFormActions(formReplayingQueue), + ); + } +} + export function retryIfBlockedOn( unblocked: Container | SuspenseInstance, ): void { @@ -467,4 +534,72 @@ export function retryIfBlockedOn( } } } + + if (enableFormActions) { + // Check the document if there are any queued form actions. + const root = unblocked.getRootNode(); + const formReplayingQueue: void | FormReplayingQueue = (root: any) + .$$reactFormReplay; + if (formReplayingQueue != null) { + for (let i = 0; i < formReplayingQueue.length; i += 3) { + const form: HTMLFormElement = formReplayingQueue[i]; + const submitterOrAction: + | null + | HTMLInputElement + | HTMLButtonElement + | FormAction = formReplayingQueue[i + 1]; + const formProps = getFiberCurrentPropsFromNode(form); + if (typeof submitterOrAction === 'function') { + // This action has already resolved. We're just waiting to dispatch it. + if (!formProps) { + // This was not part of this React instance. It might have been recently + // unblocking us from dispatching our events. So let's make sure we schedule + // a retry. + scheduleReplayQueueIfNeeded(formReplayingQueue); + } + continue; + } + let target: Node = form; + if (formProps) { + // This form belongs to this React instance but the submitter might + // not be done yet. + let action: null | FormAction = null; + const submitter = submitterOrAction; + if (submitter && submitter.hasAttribute('formAction')) { + // The submitter is the one that is responsible for the action. + target = submitter; + const submitterProps = getFiberCurrentPropsFromNode(submitter); + if (submitterProps) { + // The submitter is part of this instance. + action = (submitterProps: any).formAction; + } else { + const blockedOn = findInstanceBlockingTarget(target); + if (blockedOn !== null) { + // The submitter is not hydrated yet. We'll wait for it. + continue; + } + // The submitter must have been a part of a different React instance. + // Except the form isn't. We don't dispatch actions in this scenario. + } + } else { + action = (formProps: any).action; + } + if (typeof action === 'function') { + formReplayingQueue[i + 1] = action; + } else { + // Something went wrong so let's just delete this action. + formReplayingQueue.splice(i, 3); + i -= 3; + } + // Schedule a replay in case this unblocked something. + scheduleReplayQueueIfNeeded(formReplayingQueue); + continue; + } + // Something above this target is still blocked so we can't continue yet. + // We're not sure if this target is actually part of this React instance + // yet. It could be a different React as a child but at least some parent is. + // We must continue for any further queued actions. + } + } + } } diff --git a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js index 1fc0091b1c1cd..f2800af5e12a5 100644 --- a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js @@ -114,3 +114,11 @@ function extractEvents( } export {extractEvents}; + +export function dispatchReplayedFormAction( + formInst: Fiber, + action: FormData => void | Promise, + formData: FormData, +): void { + startHostTransition(formInst, action, formData); +} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 1b03652ef8ce7..279eef15764f0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -65,6 +65,7 @@ import { completeBoundary as completeBoundaryFunction, completeBoundaryWithStyles as styleInsertionFunction, completeSegment as completeSegmentFunction, + formReplaying as formReplayingRuntime, } from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings'; import { @@ -104,11 +105,12 @@ const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; export type InstructionState = number; -const NothingSent /* */ = 0b0000; -const SentCompleteSegmentFunction /* */ = 0b0001; -const SentCompleteBoundaryFunction /* */ = 0b0010; -const SentClientRenderFunction /* */ = 0b0100; -const SentStyleInsertionFunction /* */ = 0b1000; +const NothingSent /* */ = 0b00000; +const SentCompleteSegmentFunction /* */ = 0b00001; +const SentCompleteBoundaryFunction /* */ = 0b00010; +const SentClientRenderFunction /* */ = 0b00100; +const SentStyleInsertionFunction /* */ = 0b01000; +const SentFormReplayingRuntime /* */ = 0b10000; // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { @@ -637,6 +639,7 @@ const actionJavaScriptURL = stringToPrecomputedChunk( function pushFormActionAttribute( target: Array, + responseState: ResponseState, formAction: any, formEncType: any, formMethod: any, @@ -683,6 +686,7 @@ function pushFormActionAttribute( actionJavaScriptURL, attributeEnd, ); + injectFormReplayingRuntime(responseState); } else { // Plain form actions support all the properties, so we have to emit them. if (name !== null) { @@ -1256,9 +1260,30 @@ function pushStartOption( return children; } +const formReplayingRuntimeScript = + stringToPrecomputedChunk(formReplayingRuntime); + +function injectFormReplayingRuntime(responseState: ResponseState): void { + // If we haven't sent it yet, inject the runtime that tracks submitted JS actions + // for later replaying by Fiber. If we use an external runtime, we don't need + // to emit anything. It's always used. + if ( + (responseState.instructions & SentFormReplayingRuntime) === NothingSent && + (!enableFizzExternalRuntime || !responseState.externalRuntimeConfig) + ) { + responseState.instructions |= SentFormReplayingRuntime; + responseState.bootstrapChunks.unshift( + responseState.startInlineScript, + formReplayingRuntimeScript, + endInlineScript, + ); + } +} + function pushStartForm( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { target.push(startChunkForTag('form')); @@ -1335,6 +1360,7 @@ function pushStartForm( actionJavaScriptURL, attributeEnd, ); + injectFormReplayingRuntime(responseState); } else { // Plain form actions support all the properties, so we have to emit them. if (formAction !== null) { @@ -1365,6 +1391,7 @@ function pushStartForm( function pushInput( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { if (__DEV__) { checkControlledValueProps('input', props); @@ -1445,6 +1472,7 @@ function pushInput( pushFormActionAttribute( target, + responseState, formAction, formEncType, formMethod, @@ -1499,6 +1527,7 @@ function pushInput( function pushStartButton( target: Array, props: Object, + responseState: ResponseState, ): ReactNodeList { target.push(startChunkForTag('button')); @@ -1561,6 +1590,7 @@ function pushStartButton( pushFormActionAttribute( target, + responseState, formAction, formEncType, formMethod, @@ -2947,11 +2977,11 @@ export function pushStartInstance( case 'textarea': return pushStartTextArea(target, props); case 'input': - return pushInput(target, props); + return pushInput(target, props, responseState); case 'button': - return pushStartButton(target, props); + return pushStartButton(target, props, responseState); case 'form': - return pushStartForm(target, props); + return pushStartForm(target, props, responseState); case 'menuitem': return pushStartMenuItem(target, props); case 'title': @@ -3127,7 +3157,7 @@ export function pushEndInstance( target.push(endTag1, stringToChunk(type), endTag2); } -export function writeCompletedRoot( +function writeBootstrap( destination: Destination, responseState: ResponseState, ): boolean { @@ -3137,11 +3167,20 @@ export function writeCompletedRoot( writeChunk(destination, bootstrapChunks[i]); } if (i < bootstrapChunks.length) { - return writeChunkAndReturn(destination, bootstrapChunks[i]); + const lastChunk = bootstrapChunks[i]; + bootstrapChunks.length = 0; + return writeChunkAndReturn(destination, lastChunk); } return true; } +export function writeCompletedRoot( + destination: Destination, + responseState: ResponseState, +): boolean { + return writeBootstrap(destination, responseState); +} + // Structural Nodes // 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( writeChunk(destination, completeBoundaryScript3b); } } + let writeMore; if (scriptFormat) { - return writeChunkAndReturn(destination, completeBoundaryScriptEnd); + writeMore = writeChunkAndReturn(destination, completeBoundaryScriptEnd); } else { - return writeChunkAndReturn(destination, completeBoundaryDataEnd); + writeMore = writeChunkAndReturn(destination, completeBoundaryDataEnd); } + return writeBootstrap(destination, responseState) && writeMore; } const clientRenderScript1Full = stringToPrecomputedChunk( diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js new file mode 100644 index 0000000000000..2dcaf926adcb0 --- /dev/null +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineFormReplaying.js @@ -0,0 +1,8 @@ +import {listenToFormSubmissionsForReplaying} from './ReactDOMFizzInstructionSetShared'; + +// TODO: Export a helper function that throws the error from javascript URLs instead. +// We can do that here since we mess with globals anyway and we can guarantee it has loaded. +// It makes less sense in the external runtime since it's async loaded and doesn't expose globals +// so we might have to have two different URLs. + +listenToFormSubmissionsForReplaying(); diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js index 5600b1940f7ef..dc5232c8eeda1 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js @@ -6,8 +6,11 @@ import { clientRenderBoundary, completeBoundary, completeSegment, + listenToFormSubmissionsForReplaying, } from './ReactDOMFizzInstructionSetShared'; +import {enableFormActions} from 'shared/ReactFeatureFlags'; + export {clientRenderBoundary, completeBoundary, completeSegment}; const resourceMap = new Map(); @@ -136,3 +139,7 @@ export function completeBoundaryWithStyles( ), ); } + +if (enableFormActions) { + listenToFormSubmissionsForReplaying(); +} diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index a7247fe0c5d60..d7195d43ca063 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -9,3 +9,5 @@ export const completeBoundaryWithStyles = '$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"))};'; export const completeSegment = '$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)};'; +export const formReplaying = + '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))}});'; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index 6058eddaad7eb..4d826753dc097 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -125,3 +125,84 @@ export function completeSegment(containerID, placeholderID) { } placeholderNode.parentNode.removeChild(placeholderNode); } + +// This is the exact URL string we expect that Fizz renders if we provide a function action. +// We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense +// as a shared module for that reason. +const EXPECTED_FORM_ACTION_URL = + // eslint-disable-next-line no-script-url + "javascript:throw new Error('A React form was unexpectedly submitted.')"; + +export function listenToFormSubmissionsForReplaying() { + // A global replay queue ensures actions are replayed in order. + // This event listener should be above the React one. That way when + // we preventDefault in React's handling we also prevent this event + // from queing it. Since React listens to the root and the top most + // container you can use is the document, the window is fine. + // eslint-disable-next-line no-restricted-globals + addEventListener('submit', event => { + if (event.defaultPrevented) { + // We let earlier events to prevent the action from submitting. + return; + } + const form = event.target; + const submitter = event['submitter']; + let action = form.action; + let formDataSubmitter = submitter; + if (submitter) { + const submitterAction = submitter.getAttribute('formAction'); + if (submitterAction != null) { + // The submitter overrides the action. + action = submitterAction; + // If the submitter overrides the action, and it passes the test below, + // that means that it was a function action which conceptually has no name. + // Therefore, we exclude the submitter from the formdata. + formDataSubmitter = null; + } + } + if (action !== EXPECTED_FORM_ACTION_URL) { + // The form is a regular form action, we can bail. + return; + } + + // Prevent native navigation. + // This will also prevent other React's on the same page from listening. + event.preventDefault(); + + // Take a snapshot of the FormData at the time of the event. + let formData; + if (formDataSubmitter) { + // The submitter's value should be included in the FormData. + // It should be in the document order in the form. + // Since the FormData constructor invokes the formdata event it also + // needs to be available before that happens so after construction it's too + // late. We use a temporary fake node for the duration of this event. + // TODO: FormData takes a second argument that it's the submitter but this + // is fairly new so not all browsers support it yet. Switch to that technique + // when available. + const temp = document.createElement('input'); + temp.name = formDataSubmitter.name; + temp.value = formDataSubmitter.value; + formDataSubmitter.parentNode.insertBefore(temp, formDataSubmitter); + formData = new FormData(form); + temp.parentNode.removeChild(temp); + } else { + formData = new FormData(form); + } + + // Queue for replaying later. This field could potentially be shared with multiple + // Reacts on the same page since each one will preventDefault for the next one. + // This means that this protocol is shared with any React version that shares the same + // javascript: URL placeholder value. So we might not be the first to declare it. + // We attach it to the form's root node, which is the shared environment context + // where we preserve sequencing and where we'll pick it up from during hydration. + // In practice, this is just the same as document but we might support shadow trees + // in the future. + const root = form.getRootNode(); + (root['$$reactFormReplay'] = root['$$reactFormReplay'] || []).push( + form, + submitter, + formData, + ); + }); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index 900f270b9fba5..efd151995d08a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -65,7 +67,9 @@ describe('ReactDOMFizzForm', () => { } result += Buffer.from(value).toString('utf8'); } - container.innerHTML = result; + const temp = document.createElement('div'); + temp.innerHTML = result; + insertNodesAndExecuteScripts(temp, container, null); } // @gate enableFormActions @@ -378,4 +382,75 @@ describe('ReactDOMFizzForm', () => { await act(() => ReactDOMClient.hydrateRoot(container, )); expect(container.textContent).toBe('Pending: false'); }); + + // @gate enableFormActions + it('should replay a form action after hydration', async () => { + let foo; + function action(formData) { + foo = formData.get('foo'); + } + function App() { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + + // Dispatch an event before hydration + submit(container.getElementsByTagName('form')[0]); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + // It should've now been replayed + expect(foo).toBe('bar'); + }); + + // @gate enableFormActions + it('should replay input/button formAction', async () => { + let rootActionCalled = false; + let savedTitle = null; + let deletedTitle = null; + + function action(formData) { + rootActionCalled = true; + } + + function saveItem(formData) { + savedTitle = formData.get('title'); + } + + function deleteItem(formData) { + deletedTitle = formData.get('title'); + } + + function App() { + return ( +
+ + + +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + + submit(container.getElementsByTagName('input')[1]); + submit(container.getElementsByTagName('button')[0]); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe('Hello'); + expect(rootActionCalled).toBe(false); + }); }); diff --git a/scripts/rollup/externs/closure-externs.js b/scripts/rollup/externs/closure-externs.js new file mode 100644 index 0000000000000..f8eeb0e368eb4 --- /dev/null +++ b/scripts/rollup/externs/closure-externs.js @@ -0,0 +1,9 @@ +/** + * @externs + */ +/* eslint-disable */ + +'use strict'; + +/** @type {function} */ +var addEventListener; diff --git a/scripts/rollup/generate-inline-fizz-runtime.js b/scripts/rollup/generate-inline-fizz-runtime.js index a84f721b5612d..941bbeec69785 100644 --- a/scripts/rollup/generate-inline-fizz-runtime.js +++ b/scripts/rollup/generate-inline-fizz-runtime.js @@ -29,6 +29,10 @@ const config = [ entry: 'ReactDOMFizzInlineCompleteSegment.js', exportName: 'completeSegment', }, + { + entry: 'ReactDOMFizzInlineFormReplaying.js', + exportName: 'formReplaying', + }, ]; const prettierConfig = require('../../.prettierrc.js'); @@ -40,6 +44,7 @@ async function main() { const compiler = new ClosureCompiler({ entry_point: fullEntryPath, js: [ + require.resolve('./externs/closure-externs.js'), fullEntryPath, instructionDir + '/ReactDOMFizzInstructionSetInlineSource.js', instructionDir + '/ReactDOMFizzInstructionSetShared.js',