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',