Skip to content

[Fiber] Replay onChange Events if input/textarea/select has changed before hydration #33129

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 5 commits into from
May 6, 2025

Conversation

sebmarkbage
Copy link
Collaborator

This fixes a long standing issue that controlled inputs gets out of sync with the browser state if it's changed before we hydrate.

This resolves the issue by replaying the change events (click, input and change) if the value has changed by the time we commit the hydration. That way you can reflect the new value in state to bring it in sync. It does this whether controlled or uncontrolled.

The idea is that this should be ok to replay because it's similar to the continuous events in that it doesn't replay a sequence but only reflects the current state of the tree.

Since this is a breaking change I added it behind enableHydrationChangeEvent flag.

There is still an additional issue remaining that I intend to address in a follow up. If a useLayoutEffect triggers an sync rerender on hydration (always a bad idea) then that can rerender before we have had a chance to replay the change events. If that renders through a input then that input will always override the browser value with the controlled value. Which will reset it before we've had a change to update to the new value.

@react-sizebot
Copy link

react-sizebot commented May 6, 2025

Comparing: 79586c7...0812b1f

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 528.27 kB 528.29 kB = 93.14 kB 93.15 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.80% 633.90 kB 639.00 kB +0.77% 111.33 kB 112.19 kB
facebook-www/ReactDOM-prod.classic.js = 671.68 kB 671.70 kB = 117.77 kB 117.79 kB
facebook-www/ReactDOM-prod.modern.js = 661.96 kB 661.98 kB +0.01% 116.21 kB 116.23 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
facebook-react-native/react-dom/cjs/ReactDOMClient-prod.js +0.90% 563.48 kB 568.57 kB +0.85% 99.12 kB 99.97 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-prod.js +0.89% 568.99 kB 574.08 kB +0.85% 100.21 kB 101.06 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-profiling.js +0.86% 591.71 kB 596.77 kB +0.79% 103.15 kB 103.96 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-profiling.js +0.85% 597.65 kB 602.71 kB +0.79% 104.30 kB 105.12 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.80% 633.90 kB 639.00 kB +0.77% 111.33 kB 112.19 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +0.79% 648.31 kB 653.41 kB +0.74% 114.92 kB 115.77 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +0.73% 696.07 kB 701.15 kB +0.71% 120.41 kB 121.26 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-dev.js +0.49% 1,021.20 kB 1,026.18 kB +0.59% 171.05 kB 172.07 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-dev.js +0.48% 1,037.52 kB 1,042.51 kB +0.58% 173.87 kB 174.88 kB
oss-experimental/react-dom/cjs/react-dom-client.development.js +0.43% 1,154.02 kB 1,159.00 kB +0.53% 191.56 kB 192.57 kB
oss-experimental/react-dom/cjs/react-dom-profiling.development.js +0.43% 1,170.41 kB 1,175.39 kB +0.52% 194.42 kB 195.43 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js +0.43% 1,170.56 kB 1,175.54 kB +0.51% 195.26 kB 196.26 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.production.js +0.20% 402.19 kB 403.00 kB +0.16% 64.80 kB 64.90 kB
oss-stable/react-reconciler/cjs/react-reconciler.production.js +0.20% 402.21 kB 403.02 kB +0.16% 64.82 kB 64.92 kB

Generated by 🚫 dangerJS against 0812b1f

This is like commitMount but for hydration. Allows us to do work in the
commit phase.

We need to separate Update from Hydrate so I reuse the Callback flag for this.
Just prepares to allow for the next commit.
hydrateInput/Select/Textarea is like initInput/Select/Textarea except we
don't actually set any defaultValue or value or name etc. we assume that
they're what we expected just like any attribute hydration in prod.

If the value has changed by the time we commit, we should track the value
that we last observed. Any new value should trigger an onChange so the
initial tracked value should be what the server rendered which we assume
was the same thing we got from the hydrating props.
…ation

We do this by simulating a real DOM event so that custom listeners get to
observe it as well.
Copy link
Member

@rickhanlonii rickhanlonii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dope

await act(async () => {
setUntrackedChecked.call(b, true);
dispatchEventOnNode(b, 'click');
});
assertLog(['click b']);
if (gate(flags => flags.enableHydrationChangeEvent)) {
// Since we already had this selected, this doesn't trigger a change again.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

expect(changeCount).toBe(0);
expect(changeCount).toBe(
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of nice test comments fixed, sick

// eslint-disable-next-line jest/no-disabled-tests
it.skip('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
// @gate enableHydrationChangeEvent
it('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

niceee

@sebmarkbage sebmarkbage merged commit 587cb8f into facebook:main May 6, 2025
239 checks passed
hydrateTextarea(domElement, props.value, props.defaultValue);
break;
case 'img':
// TODO: Should we replay onLoad events?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gnoff One thing this enables us to do now is replay the onLoad event for images if they have already loaded before hydration happens. Should we?

github-actions bot pushed a commit that referenced this pull request May 6, 2025
…efore hydration (#33129)

This fixes a long standing issue that controlled inputs gets out of sync
with the browser state if it's changed before we hydrate.

This resolves the issue by replaying the change events (click, input and
change) if the value has changed by the time we commit the hydration.
That way you can reflect the new value in state to bring it in sync. It
does this whether controlled or uncontrolled.

The idea is that this should be ok to replay because it's similar to the
continuous events in that it doesn't replay a sequence but only reflects
the current state of the tree.

Since this is a breaking change I added it behind
`enableHydrationChangeEvent` flag.

There is still an additional issue remaining that I intend to address in
a follow up. If a `useLayoutEffect` triggers an sync rerender on
hydration (always a bad idea) then that can rerender before we have had
a chance to replay the change events. If that renders through a input
then that input will always override the browser value with the
controlled value. Which will reset it before we've had a change to
update to the new value.

DiffTrain build for [587cb8f](587cb8f)
github-actions bot pushed a commit that referenced this pull request May 6, 2025
…efore hydration (#33129)

This fixes a long standing issue that controlled inputs gets out of sync
with the browser state if it's changed before we hydrate.

This resolves the issue by replaying the change events (click, input and
change) if the value has changed by the time we commit the hydration.
That way you can reflect the new value in state to bring it in sync. It
does this whether controlled or uncontrolled.

The idea is that this should be ok to replay because it's similar to the
continuous events in that it doesn't replay a sequence but only reflects
the current state of the tree.

Since this is a breaking change I added it behind
`enableHydrationChangeEvent` flag.

There is still an additional issue remaining that I intend to address in
a follow up. If a `useLayoutEffect` triggers an sync rerender on
hydration (always a bad idea) then that can rerender before we have had
a chance to replay the change events. If that renders through a input
then that input will always override the browser value with the
controlled value. Which will reset it before we've had a change to
update to the new value.

DiffTrain build for [587cb8f](587cb8f)
sebmarkbage added a commit that referenced this pull request May 6, 2025
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.
github-actions bot pushed a commit that referenced this pull request May 6, 2025
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.

DiffTrain build for [54a5072](54a5072)
github-actions bot pushed a commit that referenced this pull request May 6, 2025
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.

DiffTrain build for [54a5072](54a5072)
github-actions bot pushed a commit to code/lib-react that referenced this pull request May 6, 2025
Stacked on facebook#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.

DiffTrain build for [54a5072](facebook@54a5072)
github-actions bot pushed a commit to code/lib-react that referenced this pull request May 6, 2025
Stacked on facebook#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.

DiffTrain build for [54a5072](facebook@54a5072)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants