Skip to content

Commit bf449ee

Browse files
authored
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.
1 parent 64d6be7 commit bf449ee

11 files changed

+396
-19
lines changed

packages/react-dom-bindings/src/events/ReactDOMEventListener.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,19 +225,25 @@ export function dispatchEvent(
225225
);
226226
}
227227

228+
export function findInstanceBlockingEvent(
229+
nativeEvent: AnyNativeEvent,
230+
): null | Container | SuspenseInstance {
231+
const nativeEventTarget = getEventTarget(nativeEvent);
232+
return findInstanceBlockingTarget(nativeEventTarget);
233+
}
234+
228235
export let return_targetInst: null | Fiber = null;
229236

230237
// Returns a SuspenseInstance or Container if it's blocked.
231238
// 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,
234241
): null | Container | SuspenseInstance {
235242
// TODO: Warn if _enabled is false.
236243

237244
return_targetInst = null;
238245

239-
const nativeEventTarget = getEventTarget(nativeEvent);
240-
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
246+
let targetInst = getClosestInstanceFromNode(targetNode);
241247

242248
if (targetInst !== null) {
243249
const nearestMounted = getNearestMountedFiber(targetInst);

packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,20 @@ import {
2323
getContainerFromFiber,
2424
getSuspenseInstanceFromFiber,
2525
} from 'react-reconciler/src/ReactFiberTreeReflection';
26-
import {findInstanceBlockingEvent} from './ReactDOMEventListener';
26+
import {
27+
findInstanceBlockingEvent,
28+
findInstanceBlockingTarget,
29+
} from './ReactDOMEventListener';
2730
import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent';
2831
import {
2932
getInstanceFromNode,
3033
getClosestInstanceFromNode,
34+
getFiberCurrentPropsFromNode,
3135
} from '../client/ReactDOMComponentTree';
3236
import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags';
3337
import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities';
3438
import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration';
39+
import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin';
3540

3641
import {
3742
attemptContinuousHydration,
@@ -41,6 +46,7 @@ import {
4146
runWithPriority as attemptHydrationAtPriority,
4247
getCurrentUpdatePriority,
4348
} from 'react-reconciler/src/ReactEventPriorities';
49+
import {enableFormActions} from 'shared/ReactFeatureFlags';
4450

4551
// TODO: Upgrade this definition once we're on a newer version of Flow that
4652
// has this definition built-in.
@@ -105,7 +111,7 @@ const discreteReplayableEvents: Array<DOMEventName> = [
105111
'change',
106112
'contextmenu',
107113
'reset',
108-
'submit',
114+
// 'submit', // stopPropagation blocks the replay mechanism
109115
];
110116

111117
export function isDiscreteEventThatRequiresHydration(
@@ -430,6 +436,67 @@ function scheduleCallbackIfUnblocked(
430436
}
431437
}
432438

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+
433500
export function retryIfBlockedOn(
434501
unblocked: Container | SuspenseInstance,
435502
): void {
@@ -467,4 +534,72 @@ export function retryIfBlockedOn(
467534
}
468535
}
469536
}
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+
}
470605
}

packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,11 @@ function extractEvents(
114114
}
115115

116116
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

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
completeBoundary as completeBoundaryFunction,
6666
completeBoundaryWithStyles as styleInsertionFunction,
6767
completeSegment as completeSegmentFunction,
68+
formReplaying as formReplayingRuntime,
6869
} from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings';
6970

7071
import {
@@ -104,11 +105,12 @@ const ScriptStreamingFormat: StreamingFormat = 0;
104105
const DataStreamingFormat: StreamingFormat = 1;
105106

106107
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;
112114

113115
// Per response, global state that is not contextual to the rendering subtree.
114116
export type ResponseState = {
@@ -637,6 +639,7 @@ const actionJavaScriptURL = stringToPrecomputedChunk(
637639

638640
function pushFormActionAttribute(
639641
target: Array<Chunk | PrecomputedChunk>,
642+
responseState: ResponseState,
640643
formAction: any,
641644
formEncType: any,
642645
formMethod: any,
@@ -683,6 +686,7 @@ function pushFormActionAttribute(
683686
actionJavaScriptURL,
684687
attributeEnd,
685688
);
689+
injectFormReplayingRuntime(responseState);
686690
} else {
687691
// Plain form actions support all the properties, so we have to emit them.
688692
if (name !== null) {
@@ -1256,9 +1260,30 @@ function pushStartOption(
12561260
return children;
12571261
}
12581262

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+
12591283
function pushStartForm(
12601284
target: Array<Chunk | PrecomputedChunk>,
12611285
props: Object,
1286+
responseState: ResponseState,
12621287
): ReactNodeList {
12631288
target.push(startChunkForTag('form'));
12641289

@@ -1335,6 +1360,7 @@ function pushStartForm(
13351360
actionJavaScriptURL,
13361361
attributeEnd,
13371362
);
1363+
injectFormReplayingRuntime(responseState);
13381364
} else {
13391365
// Plain form actions support all the properties, so we have to emit them.
13401366
if (formAction !== null) {
@@ -1365,6 +1391,7 @@ function pushStartForm(
13651391
function pushInput(
13661392
target: Array<Chunk | PrecomputedChunk>,
13671393
props: Object,
1394+
responseState: ResponseState,
13681395
): ReactNodeList {
13691396
if (__DEV__) {
13701397
checkControlledValueProps('input', props);
@@ -1445,6 +1472,7 @@ function pushInput(
14451472

14461473
pushFormActionAttribute(
14471474
target,
1475+
responseState,
14481476
formAction,
14491477
formEncType,
14501478
formMethod,
@@ -1499,6 +1527,7 @@ function pushInput(
14991527
function pushStartButton(
15001528
target: Array<Chunk | PrecomputedChunk>,
15011529
props: Object,
1530+
responseState: ResponseState,
15021531
): ReactNodeList {
15031532
target.push(startChunkForTag('button'));
15041533

@@ -1561,6 +1590,7 @@ function pushStartButton(
15611590

15621591
pushFormActionAttribute(
15631592
target,
1593+
responseState,
15641594
formAction,
15651595
formEncType,
15661596
formMethod,
@@ -2947,11 +2977,11 @@ export function pushStartInstance(
29472977
case 'textarea':
29482978
return pushStartTextArea(target, props);
29492979
case 'input':
2950-
return pushInput(target, props);
2980+
return pushInput(target, props, responseState);
29512981
case 'button':
2952-
return pushStartButton(target, props);
2982+
return pushStartButton(target, props, responseState);
29532983
case 'form':
2954-
return pushStartForm(target, props);
2984+
return pushStartForm(target, props, responseState);
29552985
case 'menuitem':
29562986
return pushStartMenuItem(target, props);
29572987
case 'title':
@@ -3127,7 +3157,7 @@ export function pushEndInstance(
31273157
target.push(endTag1, stringToChunk(type), endTag2);
31283158
}
31293159

3130-
export function writeCompletedRoot(
3160+
function writeBootstrap(
31313161
destination: Destination,
31323162
responseState: ResponseState,
31333163
): boolean {
@@ -3137,11 +3167,20 @@ export function writeCompletedRoot(
31373167
writeChunk(destination, bootstrapChunks[i]);
31383168
}
31393169
if (i < bootstrapChunks.length) {
3140-
return writeChunkAndReturn(destination, bootstrapChunks[i]);
3170+
const lastChunk = bootstrapChunks[i];
3171+
bootstrapChunks.length = 0;
3172+
return writeChunkAndReturn(destination, lastChunk);
31413173
}
31423174
return true;
31433175
}
31443176

3177+
export function writeCompletedRoot(
3178+
destination: Destination,
3179+
responseState: ResponseState,
3180+
): boolean {
3181+
return writeBootstrap(destination, responseState);
3182+
}
3183+
31453184
// Structural Nodes
31463185

31473186
// 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(
35993638
writeChunk(destination, completeBoundaryScript3b);
36003639
}
36013640
}
3641+
let writeMore;
36023642
if (scriptFormat) {
3603-
return writeChunkAndReturn(destination, completeBoundaryScriptEnd);
3643+
writeMore = writeChunkAndReturn(destination, completeBoundaryScriptEnd);
36043644
} else {
3605-
return writeChunkAndReturn(destination, completeBoundaryDataEnd);
3645+
writeMore = writeChunkAndReturn(destination, completeBoundaryDataEnd);
36063646
}
3647+
return writeBootstrap(destination, responseState) && writeMore;
36073648
}
36083649

36093650
const clientRenderScript1Full = stringToPrecomputedChunk(

0 commit comments

Comments
 (0)