Skip to content

Commit

Permalink
Replay Client Actions After Hydration (facebook#26716)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored and AndyPengc12 committed Apr 15, 2024
1 parent dae14af commit 657f874
Show file tree
Hide file tree
Showing 11 changed files with 396 additions and 19 deletions.
14 changes: 10 additions & 4 deletions packages/react-dom-bindings/src/events/ReactDOMEventListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
139 changes: 137 additions & 2 deletions packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -105,7 +111,7 @@ const discreteReplayableEvents: Array<DOMEventName> = [
'change',
'contextmenu',
'reset',
'submit',
// 'submit', // stopPropagation blocks the replay mechanism
];

export function isDiscreteEventThatRequiresHydration(
Expand Down Expand Up @@ -430,6 +436,67 @@ function scheduleCallbackIfUnblocked(
}
}

type FormAction = FormData => void | Promise<void>;

type FormReplayingQueue = Array<any>; // [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 {
Expand Down Expand Up @@ -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.
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,11 @@ function extractEvents(
}

export {extractEvents};

export function dispatchReplayedFormAction(
formInst: Fiber,
action: FormData => void | Promise<void>,
formData: FormData,
): void {
startHostTransition(formInst, action, formData);
}
65 changes: 53 additions & 12 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
completeBoundary as completeBoundaryFunction,
completeBoundaryWithStyles as styleInsertionFunction,
completeSegment as completeSegmentFunction,
formReplaying as formReplayingRuntime,
} from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings';

import {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -637,6 +639,7 @@ const actionJavaScriptURL = stringToPrecomputedChunk(

function pushFormActionAttribute(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
formAction: any,
formEncType: any,
formMethod: any,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
target.push(startChunkForTag('form'));

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1365,6 +1391,7 @@ function pushStartForm(
function pushInput(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
if (__DEV__) {
checkControlledValueProps('input', props);
Expand Down Expand Up @@ -1445,6 +1472,7 @@ function pushInput(

pushFormActionAttribute(
target,
responseState,
formAction,
formEncType,
formMethod,
Expand Down Expand Up @@ -1499,6 +1527,7 @@ function pushInput(
function pushStartButton(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
target.push(startChunkForTag('button'));

Expand Down Expand Up @@ -1561,6 +1590,7 @@ function pushStartButton(

pushFormActionAttribute(
target,
responseState,
formAction,
formEncType,
formMethod,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -3127,7 +3157,7 @@ export function pushEndInstance(
target.push(endTag1, stringToChunk(type), endTag2);
}

export function writeCompletedRoot(
function writeBootstrap(
destination: Destination,
responseState: ResponseState,
): boolean {
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 657f874

Please sign in to comment.