Skip to content

[Fiber] Replay events between commits #33130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion fixtures/ssr/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
Expand All @@ -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>
);
Expand Down
11 changes: 10 additions & 1 deletion packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ import {
DOCUMENT_FRAGMENT_NODE,
} from './HTMLNodeType';

import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
import {
flushEventReplaying,
retryIfBlockedOn,
} from '../events/ReactDOMEventReplaying';

import {
enableCreateEventHandleAPI,
Expand Down Expand Up @@ -3655,6 +3658,12 @@ export function commitHydratedSuspenseInstance(
retryIfBlockedOn(suspenseInstance);
}

export function flushHydrationEvents(): void {
if (enableHydrationChangeEvent) {
flushEventReplaying();
}
}

export function shouldDeleteUnhydratedTailInstances(
parentType: string,
): boolean {
Expand Down
19 changes: 14 additions & 5 deletions packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand All @@ -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);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
startGestureTransition,
stopViewTransition,
createViewTransitionInstance,
flushHydrationEvents,
} from './ReactFiberConfig';

import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading