@samitouri / QOS-React-1 / commits / 54a50729cc

[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.

Sebastian Markbåge committed May 6, 2025 at 00:23 UTC 54a50729cc47a884c2110d7c59dd5f850748e142
7 files changed +124 -7
fixtures/ssr/src/components/Page.js
+12 -1
@@ -11,10 +11,17 @@ const autofocusedInputs = [
11 ];
12
13 export default class Page extends Component {
14 - state = {active: false};
14 + state = {active: false, value: ''};
15 handleClick = e => {
16 this.setState({active: true});
17 };
18 + handleChange = e => {
19 + this.setState({value: e.target.value});
20 + };
21 + componentDidMount() {
22 + // Rerender on mount
23 + this.setState({mounted: true});
24 + }
25 render() {
26 const link = (
27 <a className="link" onClick={this.handleClick}>
@@ -30,6 +37,10 @@ export default class Page extends Component {
37 <p>Autofocus on page load: {autofocusedInputs}</p>
38 <p>{!this.state.active ? link : 'Thanks!'}</p>
39 {this.state.active && <p>Autofocus on update: {autofocusedInputs}</p>}
40 + <p>
41 + Controlled input:{' '}
42 + <input value={this.state.value} onChange={this.handleChange} />
43 + </p>
44 </Suspend>
45 </div>
46 );
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+10 -1
@@ -99,7 +99,10 @@ import {
99 DOCUMENT_FRAGMENT_NODE,
100 } from './HTMLNodeType';
101
102 -import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
102 +import {
103 + flushEventReplaying,
104 + retryIfBlockedOn,
105 +} from '../events/ReactDOMEventReplaying';
106
107 import {
108 enableCreateEventHandleAPI,
@@ -3655,6 +3658,12 @@ export function commitHydratedSuspenseInstance(
3658 retryIfBlockedOn(suspenseInstance);
3659 }
3660
3661 +export function flushHydrationEvents(): void {
3662 + if (enableHydrationChangeEvent) {
3663 + flushEventReplaying();
3664 + }
3665 +}
3666 +
3667 export function shouldDeleteUnhydratedTailInstances(
3668 parentType: string,
3669 ): boolean {
packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
+14 -5
@@ -472,12 +472,19 @@ function replayUnblockedEvents() {
472 }
473 }
474
475 +export function flushEventReplaying(): void {
476 + // Synchronously flush any event replaying so that it gets observed before
477 + // any new updates are applied.
478 + if (hasScheduledReplayAttempt) {
479 + replayUnblockedEvents();
480 + }
481 +}
482 +
483 export function queueChangeEvent(target: EventTarget): void {
484 if (enableHydrationChangeEvent) {
485 queuedChangeEventTargets.push(target);
486 if (!hasScheduledReplayAttempt) {
487 hasScheduledReplayAttempt = true;
480 - scheduleCallback(NormalPriority, replayUnblockedEvents);
488 }
489 }
490 }
@@ -490,10 +497,12 @@ function scheduleCallbackIfUnblocked(
497 queuedEvent.blockedOn = null;
498 if (!hasScheduledReplayAttempt) {
499 hasScheduledReplayAttempt = true;
493 - // Schedule a callback to attempt replaying as many events as are
494 - // now unblocked. This first might not actually be unblocked yet.
495 - // We could check it early to avoid scheduling an unnecessary callback.
496 - scheduleCallback(NormalPriority, replayUnblockedEvents);
500 + if (!enableHydrationChangeEvent) {
501 + // Schedule a callback to attempt replaying as many events as are
502 + // now unblocked. This first might not actually be unblocked yet.
503 + // We could check it early to avoid scheduling an unnecessary callback.
504 + scheduleCallback(NormalPriority, replayUnblockedEvents);
505 + }
506 }
507 }
508 }
packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js
+79
@@ -52,6 +52,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
52 }
53 this.setState({value: event.target.value});
54 }
55 + componentDidMount() {
56 + if (this.props.cascade) {
57 + // Trigger a cascading render immediately upon hydration which rerenders the input.
58 + this.setState({cascade: true});
59 + }
60 + }
61 render() {
62 return (
63 <input
@@ -73,6 +79,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
79 }
80 this.setState({value: event.target.value});
81 }
82 + componentDidMount() {
83 + if (this.props.cascade) {
84 + // Trigger a cascading render immediately upon hydration which rerenders the textarea.
85 + this.setState({cascade: true});
86 + }
87 + }
88 render() {
89 return (
90 <textarea
@@ -93,6 +105,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
105 }
106 this.setState({value: event.target.checked});
107 }
108 + componentDidMount() {
109 + if (this.props.cascade) {
110 + // Trigger a cascading render immediately upon hydration which rerenders the checkbox.
111 + this.setState({cascade: true});
112 + }
113 + }
114 render() {
115 return (
116 <input
@@ -114,6 +132,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
132 }
133 this.setState({value: event.target.value});
134 }
135 + componentDidMount() {
136 + if (this.props.cascade) {
137 + // Trigger a cascading render immediately upon hydration which rerenders the select.
138 + this.setState({cascade: true});
139 + }
140 + }
141 render() {
142 return (
143 <select
@@ -361,5 +385,60 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
385 gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
386 );
387 });
388 +
389 + // @gate enableHydrationChangeEvent
390 + it('should not blow away user-entered text cascading hydration to a controlled input', async () => {
391 + let changeCount = 0;
392 + await testUserInteractionBeforeClientRender(
393 + <ControlledInput onChange={() => changeCount++} cascade={true} />,
394 + );
395 + expect(changeCount).toBe(1);
396 + });
397 +
398 + // @gate enableHydrationChangeEvent
399 + it('should not blow away user-interaction cascading hydration to a controlled range input', async () => {
400 + let changeCount = 0;
401 + await testUserInteractionBeforeClientRender(
402 + <ControlledInput
403 + type="range"
404 + initialValue="0.25"
405 + onChange={() => changeCount++}
406 + cascade={true}
407 + />,
408 + '0.25',
409 + '1',
410 + );
411 + expect(changeCount).toBe(1);
412 + });
413 +
414 + // @gate enableHydrationChangeEvent
415 + it('should not blow away user-entered text cascading hydration to a controlled checkbox', async () => {
416 + let changeCount = 0;
417 + await testUserInteractionBeforeClientRender(
418 + <ControlledCheckbox onChange={() => changeCount++} cascade={true} />,
419 + true,
420 + false,
421 + 'checked',
422 + );
423 + expect(changeCount).toBe(1);
424 + });
425 +
426 + // @gate enableHydrationChangeEvent
427 + it('should not blow away user-entered text cascading hydration to a controlled textarea', async () => {
428 + let changeCount = 0;
429 + await testUserInteractionBeforeClientRender(
430 + <ControlledTextArea onChange={() => changeCount++} cascade={true} />,
431 + );
432 + expect(changeCount).toBe(1);
433 + });
434 +
435 + // @gate enableHydrationChangeEvent
436 + it('should not blow away user-selected value cascading hydration to an controlled select', async () => {
437 + let changeCount = 0;
438 + await testUserInteractionBeforeClientRender(
439 + <ControlledSelect onChange={() => changeCount++} cascade={true} />,
440 + );
441 + expect(changeCount).toBe(1);
442 + });
443 });
444 });
packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js
+1
@@ -50,6 +50,7 @@ export const commitHydratedInstance = shim;
50 export const commitHydratedContainer = shim;
51 export const commitHydratedActivityInstance = shim;
52 export const commitHydratedSuspenseInstance = shim;
53 +export const flushHydrationEvents = shim;
54 export const clearActivityBoundary = shim;
55 export const clearSuspenseBoundary = shim;
56 export const clearActivityBoundaryFromContainer = shim;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+7
@@ -109,6 +109,7 @@ import {
109 startGestureTransition,
110 stopViewTransition,
111 createViewTransitionInstance,
112 + flushHydrationEvents,
113 } from './ReactFiberConfig';
114
115 import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
@@ -3859,6 +3860,12 @@ function flushSpawnedWork(): void {
3860 }
3861 }
3862
3863 + // Eagerly flush any event replaying that we unblocked within this commit.
3864 + // This ensures that those are observed before we render any new changes.
3865 + if (supportsHydration) {
3866 + flushHydrationEvents();
3867 + }
3868 +
3869 // If layout work was scheduled, flush it now.
3870 flushSyncWorkOnAllRoots();
3871
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+1
@@ -228,6 +228,7 @@ export const commitHydratedActivityInstance =
228 export const commitHydratedSuspenseInstance =
229 $$$config.commitHydratedSuspenseInstance;
230 export const finalizeHydratedChildren = $$$config.finalizeHydratedChildren;
231 +export const flushHydrationEvents = $$$config.flushHydrationEvents;
232 export const clearActivityBoundary = $$$config.clearActivityBoundary;
233 export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
234 export const clearActivityBoundaryFromContainer =