From fb3589c55a30f28e0d41d232074406466bd660b9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 23:00:22 -0400 Subject: [PATCH 1/3] fixture --- fixtures/ssr/src/components/Page.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/fixtures/ssr/src/components/Page.js b/fixtures/ssr/src/components/Page.js index d7b4d5c813e98..1a4c6f79a3e78 100644 --- a/fixtures/ssr/src/components/Page.js +++ b/fixtures/ssr/src/components/Page.js @@ -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 = ( @@ -30,6 +37,10 @@ export default class Page extends Component {

Autofocus on page load: {autofocusedInputs}

{!this.state.active ? link : 'Thanks!'}

{this.state.active &&

Autofocus on update: {autofocusedInputs}

} +

+ Controlled input:{' '} + +

); From bf35ca970660be87aff5f3e31ac5b02d50a5d199 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 23:26:19 -0400 Subject: [PATCH 2/3] Flush event replaying at the end of the commit Outside the Commit execution context and before passive effects. --- .../src/client/ReactFiberConfigDOM.js | 11 ++++++++++- .../src/events/ReactDOMEventReplaying.js | 19 ++++++++++++++----- .../src/ReactFiberConfigWithNoHydration.js | 1 + .../src/ReactFiberWorkLoop.js | 7 +++++++ .../src/forks/ReactFiberConfig.custom.js | 1 + 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 1f7a4f915c087..7d705e059bdf8 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -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 { diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index d600917534cf6..2763b2fc129a1 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -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,10 +497,12 @@ function scheduleCallbackIfUnblocked( queuedEvent.blockedOn = null; if (!hasScheduledReplayAttempt) { hasScheduledReplayAttempt = true; - // 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. - scheduleCallback(NormalPriority, replayUnblockedEvents); + 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. + scheduleCallback(NormalPriority, replayUnblockedEvents); + } } } } diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js index 6c69281d0b834..2d472cf2bf58b 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f5b7bea38e6f8..f5c7a1e525905 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -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(); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index f3062d60dd61b..04ad1e13bdc7a 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -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 = From a37d64d64ca41016829587457698fa45878d9d27 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 6 May 2025 00:09:27 -0400 Subject: [PATCH 3/3] Add unit test --- ...OMServerIntegrationUserInteraction-test.js | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js index be0d4533af7e5..70109949baf10 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js @@ -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 ( { } 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 (