[Fiber] Replay events between commits (#33130)

Stacked on #33129. Flagged behind `enableHydrationChangeEvent`.

If you type into a controlled input before hydration and something else
rerenders like a setState in an effect, then the controlled input will
reset to whatever React thought it was. Even with event replaying that
this is stacked on, if the second render happens before event replaying
has fired in a separate task.

We don't want to flush inside the commit phase because then things like
flushSync in these events wouldn't work since they're inside the commit
stack.

This flushes all event replaying between renders by flushing it at the
end of `flushSpawned` work. We've already committed at that point and is
about to either do subsequent renders or yield to event loop for passive
effects which could have these events fired anyway. This just ensures
that they've already happened by the time subsequent renders fire. This
means that there's now a type of event that fire between sync render
passes.
This commit is contained in:
Sebastian Markbåge 2025-05-06 00:23:27 -04:00 committed by GitHub
parent 587cb8f896
commit 54a50729cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 7 deletions

View File

@ -11,10 +11,17 @@ const autofocusedInputs = [
];
export default class Page extends Component {
state = {active: false};
state = {active: false, value: ''};
handleClick = e => {
this.setState({active: true});
};
handleChange = e => {
this.setState({value: e.target.value});
};
componentDidMount() {
// Rerender on mount
this.setState({mounted: true});
}
render() {
const link = (
<a className="link" onClick={this.handleClick}>
@ -30,6 +37,10 @@ export default class Page extends Component {
<p>Autofocus on page load: {autofocusedInputs}</p>
<p>{!this.state.active ? link : 'Thanks!'}</p>
{this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>}
<p>
Controlled input:{' '}
<input value={this.state.value} onChange={this.handleChange} />
</p>
</Suspend>
</div>
);

View File

@ -99,7 +99,10 @@ import {
DOCUMENT_FRAGMENT_NODE,
} from './HTMLNodeType';
import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
import {
flushEventReplaying,
retryIfBlockedOn,
} from '../events/ReactDOMEventReplaying';
import {
enableCreateEventHandleAPI,
@ -3655,6 +3658,12 @@ export function commitHydratedSuspenseInstance(
retryIfBlockedOn(suspenseInstance);
}
export function flushHydrationEvents(): void {
if (enableHydrationChangeEvent) {
flushEventReplaying();
}
}
export function shouldDeleteUnhydratedTailInstances(
parentType: string,
): boolean {

View File

@ -472,12 +472,19 @@ function replayUnblockedEvents() {
}
}
export function flushEventReplaying(): void {
// Synchronously flush any event replaying so that it gets observed before
// any new updates are applied.
if (hasScheduledReplayAttempt) {
replayUnblockedEvents();
}
}
export function queueChangeEvent(target: EventTarget): void {
if (enableHydrationChangeEvent) {
queuedChangeEventTargets.push(target);
if (!hasScheduledReplayAttempt) {
hasScheduledReplayAttempt = true;
scheduleCallback(NormalPriority, replayUnblockedEvents);
}
}
}
@ -490,6 +497,7 @@ function scheduleCallbackIfUnblocked(
queuedEvent.blockedOn = null;
if (!hasScheduledReplayAttempt) {
hasScheduledReplayAttempt = true;
if (!enableHydrationChangeEvent) {
// Schedule a callback to attempt replaying as many events as are
// now unblocked. This first might not actually be unblocked yet.
// We could check it early to avoid scheduling an unnecessary callback.
@ -497,6 +505,7 @@ function scheduleCallbackIfUnblocked(
}
}
}
}
type FormAction = FormData => void | Promise<void>;

View File

@ -52,6 +52,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
}
this.setState({value: event.target.value});
}
componentDidMount() {
if (this.props.cascade) {
// Trigger a cascading render immediately upon hydration which rerenders the input.
this.setState({cascade: true});
}
}
render() {
return (
<input
@ -73,6 +79,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
}
this.setState({value: event.target.value});
}
componentDidMount() {
if (this.props.cascade) {
// Trigger a cascading render immediately upon hydration which rerenders the textarea.
this.setState({cascade: true});
}
}
render() {
return (
<textarea
@ -93,6 +105,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
}
this.setState({value: event.target.checked});
}
componentDidMount() {
if (this.props.cascade) {
// Trigger a cascading render immediately upon hydration which rerenders the checkbox.
this.setState({cascade: true});
}
}
render() {
return (
<input
@ -114,6 +132,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
}
this.setState({value: event.target.value});
}
componentDidMount() {
if (this.props.cascade) {
// Trigger a cascading render immediately upon hydration which rerenders the select.
this.setState({cascade: true});
}
}
render() {
return (
<select
@ -361,5 +385,60 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
);
});
// @gate enableHydrationChangeEvent
it('should not blow away user-entered text cascading hydration to a controlled input', async () => {
let changeCount = 0;
await testUserInteractionBeforeClientRender(
<ControlledInput onChange={() => changeCount++} cascade={true} />,
);
expect(changeCount).toBe(1);
});
// @gate enableHydrationChangeEvent
it('should not blow away user-interaction cascading hydration to a controlled range input', async () => {
let changeCount = 0;
await testUserInteractionBeforeClientRender(
<ControlledInput
type="range"
initialValue="0.25"
onChange={() => changeCount++}
cascade={true}
/>,
'0.25',
'1',
);
expect(changeCount).toBe(1);
});
// @gate enableHydrationChangeEvent
it('should not blow away user-entered text cascading hydration to a controlled checkbox', async () => {
let changeCount = 0;
await testUserInteractionBeforeClientRender(
<ControlledCheckbox onChange={() => changeCount++} cascade={true} />,
true,
false,
'checked',
);
expect(changeCount).toBe(1);
});
// @gate enableHydrationChangeEvent
it('should not blow away user-entered text cascading hydration to a controlled textarea', async () => {
let changeCount = 0;
await testUserInteractionBeforeClientRender(
<ControlledTextArea onChange={() => changeCount++} cascade={true} />,
);
expect(changeCount).toBe(1);
});
// @gate enableHydrationChangeEvent
it('should not blow away user-selected value cascading hydration to an controlled select', async () => {
let changeCount = 0;
await testUserInteractionBeforeClientRender(
<ControlledSelect onChange={() => changeCount++} cascade={true} />,
);
expect(changeCount).toBe(1);
});
});
});

View File

@ -50,6 +50,7 @@ export const commitHydratedInstance = shim;
export const commitHydratedContainer = shim;
export const commitHydratedActivityInstance = shim;
export const commitHydratedSuspenseInstance = shim;
export const flushHydrationEvents = shim;
export const clearActivityBoundary = shim;
export const clearSuspenseBoundary = shim;
export const clearActivityBoundaryFromContainer = shim;

View File

@ -109,6 +109,7 @@ import {
startGestureTransition,
stopViewTransition,
createViewTransitionInstance,
flushHydrationEvents,
} from './ReactFiberConfig';
import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
@ -3859,6 +3860,12 @@ function flushSpawnedWork(): void {
}
}
// Eagerly flush any event replaying that we unblocked within this commit.
// This ensures that those are observed before we render any new changes.
if (supportsHydration) {
flushHydrationEvents();
}
// If layout work was scheduled, flush it now.
flushSyncWorkOnAllRoots();

View File

@ -228,6 +228,7 @@ export const commitHydratedActivityInstance =
export const commitHydratedSuspenseInstance =
$$$config.commitHydratedSuspenseInstance;
export const finalizeHydratedChildren = $$$config.finalizeHydratedChildren;
export const flushHydrationEvents = $$$config.flushHydrationEvents;
export const clearActivityBoundary = $$$config.clearActivityBoundary;
export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
export const clearActivityBoundaryFromContainer =