@samitouri / QOS-React-1 / commits / 587cb8f896

[Fiber] Replay onChange Events if input/textarea/select has changed before 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.

Sebastian Markbåge committed May 6, 2025 at 00:10 UTC 587cb8f8967866139bbfdbae3f519cb37e68a054
22 files changed +419 -64
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+21 -16
@@ -49,7 +49,6 @@ import {
49 } from './ReactDOMTextarea';
50 import {setSrcObject} from './ReactDOMSrcObject';
51 import {validateTextNesting} from './validateDOMNesting';
52 -import {track} from './inputValueTracking';
52 import setTextContent from './setTextContent';
53 import {
54 createDangerousStringForStyles,
@@ -67,6 +66,7 @@ import sanitizeURL from '../shared/sanitizeURL';
66 import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
67
68 import {
69 + enableHydrationChangeEvent,
70 enableScrollEndPolyfill,
71 enableSrcObject,
72 enableTrustedTypesIntegration,
@@ -1187,7 +1187,6 @@ export function setInitialProperties(
1187 name,
1188 false,
1189 );
1190 - track((domElement: any));
1190 return;
1191 }
1192 case 'select': {
@@ -1285,7 +1284,6 @@ export function setInitialProperties(
1284 // up necessary since we never stop tracking anymore.
1285 validateTextareaProps(domElement, props);
1286 initTextarea(domElement, value, defaultValue, children);
1288 - track((domElement: any));
1287 return;
1288 }
1289 case 'option': {
@@ -3100,17 +3098,18 @@ export function hydrateProperties(
3098 // option and select we don't quite do the same thing and select
3099 // is not resilient to the DOM state changing so we don't do that here.
3100 // TODO: Consider not doing this for input and textarea.
3103 - initInput(
3104 - domElement,
3105 - props.value,
3106 - props.defaultValue,
3107 - props.checked,
3108 - props.defaultChecked,
3109 - props.type,
3110 - props.name,
3111 - true,
3112 - );
3113 - track((domElement: any));
3101 + if (!enableHydrationChangeEvent) {
3102 + initInput(
3103 + domElement,
3104 + props.value,
3105 + props.defaultValue,
3106 + props.checked,
3107 + props.defaultChecked,
3108 + props.type,
3109 + props.name,
3110 + true,
3111 + );
3112 + }
3113 break;
3114 case 'option':
3115 validateOptionProps(domElement, props);
@@ -3134,8 +3133,14 @@ export function hydrateProperties(
3133 // TODO: Make sure we check if this is still unmounted or do any clean
3134 // up necessary since we never stop tracking anymore.
3135 validateTextareaProps(domElement, props);
3137 - initTextarea(domElement, props.value, props.defaultValue, props.children);
3138 - track((domElement: any));
3136 + if (!enableHydrationChangeEvent) {
3137 + initTextarea(
3138 + domElement,
3139 + props.value,
3140 + props.defaultValue,
3141 + props.children,
3142 + );
3143 + }
3144 break;
3145 }
3146
packages/react-dom-bindings/src/client/ReactDOMInput.js
+47 -4
@@ -12,13 +12,17 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
12
13 import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
14 import {getToStringValue, toString} from './ToStringValue';
15 -import {updateValueIfChanged} from './inputValueTracking';
15 +import {track, trackHydrated, updateValueIfChanged} from './inputValueTracking';
16 import getActiveElement from './getActiveElement';
17 -import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
17 +import {
18 + disableInputAttributeSyncing,
19 + enableHydrationChangeEvent,
20 +} from 'shared/ReactFeatureFlags';
21 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
22
23 import type {ToStringValue} from './ToStringValue';
24 import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
25 +import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
26
27 let didWarnValueDefaultValue = false;
28 let didWarnCheckedDefaultChecked = false;
@@ -229,6 +233,8 @@ export function initInput(
233 // Avoid setting value attribute on submit/reset inputs as it overrides the
234 // default value provided by the browser. See: #12872
235 if (isButton && (value === undefined || value === null)) {
236 + // We track the value just in case it changes type later on.
237 + track((element: any));
238 return;
239 }
240
@@ -239,7 +245,7 @@ export function initInput(
245
246 // Do not assign value if it is already set. This prevents user text input
247 // from being lost during SSR hydration.
242 - if (!isHydrating) {
248 + if (!isHydrating || enableHydrationChangeEvent) {
249 if (disableInputAttributeSyncing) {
250 // When not syncing the value attribute, the value property points
251 // directly to the React prop. Only assign it if it exists.
@@ -297,7 +303,7 @@ export function initInput(
303 typeof checkedOrDefault !== 'symbol' &&
304 !!checkedOrDefault;
305
300 - if (isHydrating) {
306 + if (isHydrating && !enableHydrationChangeEvent) {
307 // Detach .checked from .defaultChecked but leave user input alone
308 node.checked = node.checked;
309 } else {
@@ -335,6 +341,43 @@ export function initInput(
341 }
342 node.name = name;
343 }
344 + track((element: any));
345 +}
346 +
347 +export function hydrateInput(
348 + element: Element,
349 + value: ?string,
350 + defaultValue: ?string,
351 + checked: ?boolean,
352 + defaultChecked: ?boolean,
353 +): void {
354 + const node: HTMLInputElement = (element: any);
355 +
356 + const defaultValueStr =
357 + defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
358 + const initialValue =
359 + value != null ? toString(getToStringValue(value)) : defaultValueStr;
360 +
361 + const checkedOrDefault = checked != null ? checked : defaultChecked;
362 + // TODO: This 'function' or 'symbol' check isn't replicated in other places
363 + // so this semantic is inconsistent.
364 + const initialChecked =
365 + typeof checkedOrDefault !== 'function' &&
366 + typeof checkedOrDefault !== 'symbol' &&
367 + !!checkedOrDefault;
368 +
369 + // Detach .checked from .defaultChecked but leave user input alone
370 + node.checked = node.checked;
371 +
372 + const changed = trackHydrated((node: any), initialValue, initialChecked);
373 + if (changed) {
374 + // If the current value is different, that suggests that the user
375 + // changed it before hydration. Queue a replay of the change event.
376 + // For radio buttons the change event only fires on the selected one.
377 + if (node.type !== 'radio' || node.checked) {
378 + queueChangeEvent(node);
379 + }
380 + }
381 }
382
383 export function restoreControlledInputState(element: Element, props: Object) {
packages/react-dom-bindings/src/client/ReactDOMSelect.js
+55 -1
@@ -12,6 +12,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
12
13 import {getToStringValue, toString} from './ToStringValue';
14 import isArray from 'shared/isArray';
15 +import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
16
17 let didWarnValueDefaultValue;
18
@@ -86,7 +87,7 @@ function updateOptions(
87 } else {
88 // Do not set `select.value` as exact behavior isn't consistent across all
89 // browsers for all cases.
89 - const selectedValue = toString(getToStringValue((propValue: any)));
90 + const selectedValue = toString(getToStringValue(propValue));
91 let defaultSelected = null;
92 for (let i = 0; i < options.length; i++) {
93 if (options[i].value === selectedValue) {
@@ -157,6 +158,59 @@ export function initSelect(
158 }
159 }
160
161 +export function hydrateSelect(
162 + element: Element,
163 + value: ?string,
164 + defaultValue: ?string,
165 + multiple: ?boolean,
166 +): void {
167 + const node: HTMLSelectElement = (element: any);
168 + const options: HTMLOptionsCollection = node.options;
169 +
170 + const propValue: any = value != null ? value : defaultValue;
171 +
172 + let changed = false;
173 +
174 + if (multiple) {
175 + const selectedValues = (propValue: ?Array<string>);
176 + const selectedValue: {[string]: boolean} = {};
177 + if (selectedValues != null) {
178 + for (let i = 0; i < selectedValues.length; i++) {
179 + // Prefix to avoid chaos with special keys.
180 + selectedValue['$' + selectedValues[i]] = true;
181 + }
182 + }
183 + for (let i = 0; i < options.length; i++) {
184 + const expectedSelected = selectedValue.hasOwnProperty(
185 + '$' + options[i].value,
186 + );
187 + if (options[i].selected !== expectedSelected) {
188 + changed = true;
189 + break;
190 + }
191 + }
192 + } else {
193 + let selectedValue =
194 + propValue == null ? null : toString(getToStringValue(propValue));
195 + for (let i = 0; i < options.length; i++) {
196 + if (selectedValue == null && !options[i].disabled) {
197 + // We expect the first non-disabled option to be selected if the selected is null.
198 + selectedValue = options[i].value;
199 + }
200 + const expectedSelected = options[i].value === selectedValue;
201 + if (options[i].selected !== expectedSelected) {
202 + changed = true;
203 + break;
204 + }
205 + }
206 + }
207 + if (changed) {
208 + // If the current selection is different than our initial that suggests that the user
209 + // changed it before hydration. Queue a replay of the change event.
210 + queueChangeEvent(node);
211 + }
212 +}
213 +
214 export function updateSelect(
215 element: Element,
216 value: ?string,
packages/react-dom-bindings/src/client/ReactDOMTextarea.js
+30
@@ -13,6 +13,9 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
13 import {getToStringValue, toString} from './ToStringValue';
14 import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
15
16 +import {track, trackHydrated} from './inputValueTracking';
17 +import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
18 +
19 let didWarnValDefaultVal = false;
20
21 /**
@@ -140,6 +143,33 @@ export function initTextarea(
143 node.value = textContent;
144 }
145 }
146 +
147 + track((element: any));
148 +}
149 +
150 +export function hydrateTextarea(
151 + element: Element,
152 + value: ?string,
153 + defaultValue: ?string,
154 +): void {
155 + const node: HTMLTextAreaElement = (element: any);
156 + let initialValue = value;
157 + if (initialValue == null) {
158 + if (defaultValue == null) {
159 + defaultValue = '';
160 + }
161 + initialValue = defaultValue;
162 + }
163 + // Track the value that we last observed which is the hydrated value so
164 + // that any change event that fires will trigger onChange on the actual
165 + // current value.
166 + const stringValue = toString(getToStringValue(initialValue));
167 + const changed = trackHydrated((node: any), stringValue, false);
168 + if (changed) {
169 + // If the current value is different, that suggests that the user
170 + // changed it before hydration. Queue a replay of the change event.
171 + queueChangeEvent(node);
172 + }
173 }
174
175 export function restoreControlledTextareaState(
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+72
@@ -75,6 +75,9 @@ import {
75 diffHydratedText,
76 trapClickOnNonInteractiveElement,
77 } from './ReactDOMComponent';
78 +import {hydrateInput} from './ReactDOMInput';
79 +import {hydrateTextarea} from './ReactDOMTextarea';
80 +import {hydrateSelect} from './ReactDOMSelect';
81 import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
82 import setTextContent from './setTextContent';
83 import {
@@ -108,6 +111,7 @@ import {
111 enableSuspenseyImages,
112 enableSrcObject,
113 enableViewTransition,
114 + enableHydrationChangeEvent,
115 } from 'shared/ReactFeatureFlags';
116 import {
117 HostComponent,
@@ -154,6 +158,10 @@ export type Props = {
158 top?: null | number,
159 is?: string,
160 size?: number,
161 + value?: string,
162 + defaultValue?: string,
163 + checked?: boolean,
164 + defaultChecked?: boolean,
165 multiple?: boolean,
166 src?: string | Blob | MediaSource | MediaStream, // TODO: Response
167 srcSet?: string,
@@ -611,6 +619,27 @@ export function finalizeInitialChildren(
619 }
620 }
621
622 +export function finalizeHydratedChildren(
623 + domElement: Instance,
624 + type: string,
625 + props: Props,
626 + hostContext: HostContext,
627 +): boolean {
628 + // TOOD: Consider unifying this with hydrateInstance.
629 + if (!enableHydrationChangeEvent) {
630 + return false;
631 + }
632 + switch (type) {
633 + case 'input':
634 + case 'select':
635 + case 'textarea':
636 + case 'img':
637 + return true;
638 + default:
639 + return false;
640 + }
641 +}
642 +
643 export function shouldSetTextContent(type: string, props: Props): boolean {
644 return (
645 type === 'textarea' ||
@@ -819,6 +848,49 @@ export function commitMount(
848 }
849 }
850
851 +export function commitHydratedInstance(
852 + domElement: Instance,
853 + type: string,
854 + props: Props,
855 + internalInstanceHandle: Object,
856 +): void {
857 + if (!enableHydrationChangeEvent) {
858 + return;
859 + }
860 + // This fires in the commit phase if a hydrated instance needs to do further
861 + // work in the commit phase. Similar to commitMount. However, this should not
862 + // do things that would've already happened such as set auto focus since that
863 + // would steal focus. It's only scheduled if finalizeHydratedChildren returns
864 + // true.
865 + switch (type) {
866 + case 'input': {
867 + hydrateInput(
868 + domElement,
869 + props.value,
870 + props.defaultValue,
871 + props.checked,
872 + props.defaultChecked,
873 + );
874 + break;
875 + }
876 + case 'select': {
877 + hydrateSelect(
878 + domElement,
879 + props.value,
880 + props.defaultValue,
881 + props.multiple,
882 + );
883 + break;
884 + }
885 + case 'textarea':
886 + hydrateTextarea(domElement, props.value, props.defaultValue);
887 + break;
888 + case 'img':
889 + // TODO: Should we replay onLoad events?
890 + break;
891 + }
892 +}
893 +
894 export function commitUpdate(
895 domElement: Instance,
896 type: string,
packages/react-dom-bindings/src/client/inputValueTracking.js
+38 -8
@@ -51,18 +51,16 @@ function getValueFromNode(node: HTMLInputElement): string {
51 return value;
52 }
53
54 -function trackValueOnNode(node: any): ?ValueTracker {
55 - const valueField = isCheckable(node) ? 'checked' : 'value';
54 +function trackValueOnNode(
55 + node: any,
56 + valueField: 'checked' | 'value',
57 + currentValue: string,
58 +): ?ValueTracker {
59 const descriptor = Object.getOwnPropertyDescriptor(
60 node.constructor.prototype,
61 valueField,
62 );
63
61 - if (__DEV__) {
62 - checkFormFieldValueStringCoercion(node[valueField]);
63 - }
64 - let currentValue = '' + node[valueField];
65 -
64 // if someone has already defined a value or Safari, then bail
65 // and don't track value will cause over reporting of changes,
66 // but it's better then a hard failure
@@ -123,7 +121,39 @@ export function track(node: ElementWithValueTracker) {
121 return;
122 }
123
126 - node._valueTracker = trackValueOnNode(node);
124 + const valueField = isCheckable(node) ? 'checked' : 'value';
125 + // This is read from the DOM so always safe to coerce. We really shouldn't
126 + // be coercing to a string at all. It's just historical.
127 + // eslint-disable-next-line react-internal/safe-string-coercion
128 + const initialValue = '' + (node[valueField]: any);
129 + node._valueTracker = trackValueOnNode(node, valueField, initialValue);
130 +}
131 +
132 +export function trackHydrated(
133 + node: ElementWithValueTracker,
134 + initialValue: string,
135 + initialChecked: boolean,
136 +): boolean {
137 + // For hydration, the initial value is not the current value but the value
138 + // that we last observed which is what the initial server render was.
139 + if (getTracker(node)) {
140 + return false;
141 + }
142 +
143 + let valueField;
144 + let expectedValue;
145 + if (isCheckable(node)) {
146 + valueField = 'checked';
147 + // eslint-disable-next-line react-internal/safe-string-coercion
148 + expectedValue = '' + (initialChecked: any);
149 + } else {
150 + valueField = 'value';
151 + expectedValue = initialValue;
152 + }
153 + // eslint-disable-next-line react-internal/safe-string-coercion
154 + const currentValue = '' + (node[valueField]: any);
155 + node._valueTracker = trackValueOnNode(node, valueField, expectedValue);
156 + return currentValue !== expectedValue;
157 }
158
159 export function updateValueIfChanged(node: ElementWithValueTracker): boolean {
packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
+50 -5
@@ -56,9 +56,11 @@ import {
56 attemptHydrationAtCurrentPriority,
57 } from 'react-reconciler/src/ReactFiberReconciler';
58
59 +import {enableHydrationChangeEvent} from 'shared/ReactFeatureFlags';
60 +
61 // TODO: Upgrade this definition once we're on a newer version of Flow that
62 // has this definition built-in.
61 -type PointerEvent = Event & {
63 +type PointerEventType = Event & {
64 pointerId: number,
65 relatedTarget: EventTarget | null,
66 ...
@@ -84,6 +86,8 @@ const queuedPointers: Map<number, QueuedReplayableEvent> = new Map();
86 const queuedPointerCaptures: Map<number, QueuedReplayableEvent> = new Map();
87 // We could consider replaying selectionchange and touchmoves too.
88
89 +const queuedChangeEventTargets: Array<EventTarget> = [];
90 +
91 type QueuedHydrationTarget = {
92 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
93 target: Node,
@@ -164,13 +168,13 @@ export function clearIfContinuousEvent(
168 break;
169 case 'pointerover':
170 case 'pointerout': {
167 - const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
171 + const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
172 queuedPointers.delete(pointerId);
173 break;
174 }
175 case 'gotpointercapture':
176 case 'lostpointercapture': {
173 - const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
177 + const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
178 queuedPointerCaptures.delete(pointerId);
179 break;
180 }
@@ -268,7 +272,7 @@ export function queueIfContinuousEvent(
272 return true;
273 }
274 case 'pointerover': {
271 - const pointerEvent = ((nativeEvent: any): PointerEvent);
275 + const pointerEvent = ((nativeEvent: any): PointerEventType);
276 const pointerId = pointerEvent.pointerId;
277 queuedPointers.set(
278 pointerId,
@@ -284,7 +288,7 @@ export function queueIfContinuousEvent(
288 return true;
289 }
290 case 'gotpointercapture': {
287 - const pointerEvent = ((nativeEvent: any): PointerEvent);
291 + const pointerEvent = ((nativeEvent: any): PointerEventType);
292 const pointerId = pointerEvent.pointerId;
293 queuedPointerCaptures.set(
294 pointerId,
@@ -421,6 +425,31 @@ function attemptReplayContinuousQueuedEventInMap(
425 }
426 }
427
428 +function replayChangeEvent(target: EventTarget): void {
429 + // Dispatch a fake "change" event for the input.
430 + const element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =
431 + (target: any);
432 + if (element.nodeName === 'INPUT') {
433 + if (element.type === 'checkbox' || element.type === 'radio') {
434 + // Checkboxes always fire a click event regardless of how the change was made.
435 + const EventCtr =
436 + typeof PointerEvent === 'function' ? PointerEvent : Event;
437 + target.dispatchEvent(new EventCtr('click', {bubbles: true}));
438 + // For checkboxes the input event uses the Event constructor instead of InputEvent.
439 + target.dispatchEvent(new Event('input', {bubbles: true}));
440 + } else {
441 + if (typeof InputEvent === 'function') {
442 + target.dispatchEvent(new InputEvent('input', {bubbles: true}));
443 + }
444 + }
445 + } else if (element.nodeName === 'TEXTAREA') {
446 + if (typeof InputEvent === 'function') {
447 + target.dispatchEvent(new InputEvent('input', {bubbles: true}));
448 + }
449 + }
450 + target.dispatchEvent(new Event('change', {bubbles: true}));
451 +}
452 +
453 function replayUnblockedEvents() {
454 hasScheduledReplayAttempt = false;
455 // Replay any continuous events.
@@ -435,6 +464,22 @@ function replayUnblockedEvents() {
464 }
465 queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
466 queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
467 + if (enableHydrationChangeEvent) {
468 + for (let i = 0; i < queuedChangeEventTargets.length; i++) {
469 + replayChangeEvent(queuedChangeEventTargets[i]);
470 + }
471 + queuedChangeEventTargets.length = 0;
472 + }
473 +}
474 +
475 +export function queueChangeEvent(target: EventTarget): void {
476 + if (enableHydrationChangeEvent) {
477 + queuedChangeEventTargets.push(target);
478 + if (!hasScheduledReplayAttempt) {
479 + hasScheduledReplayAttempt = true;
480 + scheduleCallback(NormalPriority, replayUnblockedEvents);
481 + }
482 + }
483 }
484
485 function scheduleCallbackIfUnblocked(
packages/react-dom/src/__tests__/ReactDOMInput-test.js
+33 -12
@@ -1536,10 +1536,14 @@ describe('ReactDOMInput', () => {
1536 ReactDOMClient.hydrateRoot(container, <App />);
1537 });
1538
1539 - // Currently, we don't fire onChange when hydrating
1540 - assertLog([]);
1541 - // Strangely, we leave `b` checked even though we rendered A with
1542 - // checked={true} and B with checked={false}. Arguably this is a bug.
1539 + if (gate(flags => flags.enableHydrationChangeEvent)) {
1540 + // We replayed the click since the value changed before hydration.
1541 + assertLog(['click b']);
1542 + } else {
1543 + assertLog([]);
1544 + // Strangely, we leave `b` checked even though we rendered A with
1545 + // checked={true} and B with checked={false}. Arguably this is a bug.
1546 + }
1547 expect(a.checked).toBe(false);
1548 expect(b.checked).toBe(true);
1549 expect(c.checked).toBe(false);
@@ -1554,22 +1558,35 @@ describe('ReactDOMInput', () => {
1558 dispatchEventOnNode(c, 'click');
1559 });
1560
1557 - // then since C's onClick doesn't set state, A becomes rechecked.
1561 assertLog(['click c']);
1559 - expect(a.checked).toBe(true);
1560 - expect(b.checked).toBe(false);
1561 - expect(c.checked).toBe(false);
1562 + if (gate(flags => flags.enableHydrationChangeEvent)) {
1563 + // then since C's onClick doesn't set state, B becomes rechecked.
1564 + expect(a.checked).toBe(false);
1565 + expect(b.checked).toBe(true);
1566 + expect(c.checked).toBe(false);
1567 + } else {
1568 + // then since C's onClick doesn't set state, A becomes rechecked
1569 + // since in this branch we didn't replay to select B.
1570 + expect(a.checked).toBe(true);
1571 + expect(b.checked).toBe(false);
1572 + expect(c.checked).toBe(false);
1573 + }
1574 expect(isCheckedDirty(a)).toBe(true);
1575 expect(isCheckedDirty(b)).toBe(true);
1576 expect(isCheckedDirty(c)).toBe(true);
1577 assertInputTrackingIsCurrent(container);
1578
1567 - // And we can also change to B properly after hydration.
1579 await act(async () => {
1580 setUntrackedChecked.call(b, true);
1581 dispatchEventOnNode(b, 'click');
1582 });
1572 - assertLog(['click b']);
1583 + if (gate(flags => flags.enableHydrationChangeEvent)) {
1584 + // Since we already had this selected, this doesn't trigger a change again.
1585 + assertLog([]);
1586 + } else {
1587 + // And we can also change to B properly after hydration.
1588 + assertLog(['click b']);
1589 + }
1590 expect(a.checked).toBe(false);
1591 expect(b.checked).toBe(true);
1592 expect(c.checked).toBe(false);
@@ -1628,8 +1645,12 @@ describe('ReactDOMInput', () => {
1645 ReactDOMClient.hydrateRoot(container, <App />);
1646 });
1647
1631 - // Currently, we don't fire onChange when hydrating
1632 - assertLog([]);
1648 + if (gate(flags => flags.enableHydrationChangeEvent)) {
1649 + // We replayed the click since the value changed before hydration.
1650 + assertLog(['click b']);
1651 + } else {
1652 + assertLog([]);
1653 + }
1654 expect(a.checked).toBe(false);
1655 expect(b.checked).toBe(true);
1656 expect(c.checked).toBe(false);
packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js
+17 -16
@@ -278,10 +278,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
278 await testUserInteractionBeforeClientRender(
279 <ControlledInput onChange={() => changeCount++} />,
280 );
281 - // note that there's a strong argument to be made that the DOM revival
282 - // algorithm should notice that the user has changed the value and fire
283 - // an onChange. however, it does not now, so that's what this tests.
284 - expect(changeCount).toBe(0);
281 + expect(changeCount).toBe(
282 + gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
283 + );
284 });
285
286 it('should not blow away user-interaction on successful reconnect to an uncontrolled range input', () =>
@@ -302,7 +301,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
301 '0.25',
302 '1',
303 );
305 - expect(changeCount).toBe(0);
304 + expect(changeCount).toBe(
305 + gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
306 + );
307 });
308
309 it('should not blow away user-entered text on successful reconnect to an uncontrolled checkbox', () =>
@@ -321,24 +322,22 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
322 false,
323 'checked',
324 );
324 - expect(changeCount).toBe(0);
325 + expect(changeCount).toBe(
326 + gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
327 + );
328 });
329
327 - // skipping this test because React 15 does the wrong thing. it blows
328 - // away the user's typing in the textarea.
329 - // eslint-disable-next-line jest/no-disabled-tests
330 - it.skip('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
330 + // @gate enableHydrationChangeEvent
331 + it('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
332 testUserInteractionBeforeClientRender(<textarea defaultValue="Hello" />));
333
333 - // skipping this test because React 15 does the wrong thing. it blows
334 - // away the user's typing in the textarea.
335 - // eslint-disable-next-line jest/no-disabled-tests
336 - it.skip('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
334 + // @gate enableHydrationChangeEvent
335 + it('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
336 let changeCount = 0;
337 await testUserInteractionBeforeClientRender(
338 <ControlledTextArea onChange={() => changeCount++} />,
339 );
341 - expect(changeCount).toBe(0);
340 + expect(changeCount).toBe(1);
341 });
342
343 it('should not blow away user-selected value on successful reconnect to an uncontrolled select', () =>
@@ -358,7 +357,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
357 await testUserInteractionBeforeClientRender(
358 <ControlledSelect onChange={() => changeCount++} />,
359 );
361 - expect(changeCount).toBe(0);
360 + expect(changeCount).toBe(
361 + gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
362 + );
363 });
364 });
365 });
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+23
@@ -48,6 +48,7 @@ import {
48 unhideDehydratedBoundary,
49 unhideInstance,
50 unhideTextInstance,
51 + commitHydratedInstance,
52 commitHydratedContainer,
53 commitHydratedActivityInstance,
54 commitHydratedSuspenseInstance,
@@ -87,6 +88,28 @@ export function commitHostMount(finishedWork: Fiber) {
88 }
89 }
90
91 +export function commitHostHydratedInstance(finishedWork: Fiber) {
92 + const type = finishedWork.type;
93 + const props = finishedWork.memoizedProps;
94 + const instance: Instance = finishedWork.stateNode;
95 + try {
96 + if (__DEV__) {
97 + runWithFiberInDEV(
98 + finishedWork,
99 + commitHydratedInstance,
100 + instance,
101 + type,
102 + props,
103 + finishedWork,
104 + );
105 + } else {
106 + commitHydratedInstance(instance, type, props, finishedWork);
107 + }
108 + } catch (error) {
109 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
110 + }
111 +}
112 +
113 export function commitHostUpdate(
114 finishedWork: Fiber,
115 newProps: any,
packages/react-reconciler/src/ReactFiberCommitWork.js
+8 -2
@@ -93,6 +93,7 @@ import {
93 ChildDeletion,
94 Snapshot,
95 Update,
96 + Hydrate,
97 Callback,
98 Ref,
99 Hydrating,
@@ -227,6 +228,7 @@ import {
228 } from './ReactFiberCommitEffects';
229 import {
230 commitHostMount,
231 + commitHostHydratedInstance,
232 commitHostUpdate,
233 commitHostTextUpdate,
234 commitHostResetTextContent,
@@ -663,8 +665,12 @@ function commitLayoutEffectOnFiber(
665 // (eg DOM renderer may schedule auto-focus for inputs and form controls).
666 // These effects should only be committed when components are first mounted,
667 // aka when there is no current/alternate.
666 - if (current === null && flags & Update) {
667 - commitHostMount(finishedWork);
668 + if (current === null) {
669 + if (flags & Update) {
670 + commitHostMount(finishedWork);
671 + } else if (flags & Hydrate) {
672 + commitHostHydratedInstance(finishedWork);
673 + }
674 }
675
676 if (flags & Ref) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+12
@@ -102,6 +102,7 @@ import {
102 ShouldSuspendCommit,
103 Cloned,
104 ViewTransitionStatic,
105 + Hydrate,
106 } from './ReactFiberFlags';
107
108 import {
@@ -110,6 +111,7 @@ import {
111 resolveSingletonInstance,
112 appendInitialChild,
113 finalizeInitialChildren,
114 + finalizeHydratedChildren,
115 supportsMutation,
116 supportsPersistence,
117 supportsResources,
@@ -1391,6 +1393,16 @@ function completeWork(
1393 // TODO: Move this and createInstance step into the beginPhase
1394 // to consolidate.
1395 prepareToHydrateHostInstance(workInProgress, currentHostContext);
1396 + if (
1397 + finalizeHydratedChildren(
1398 + workInProgress.stateNode,
1399 + type,
1400 + newProps,
1401 + currentHostContext,
1402 + )
1403 + ) {
1404 + workInProgress.flags |= Hydrate;
1405 + }
1406 } else {
1407 const rootContainerInstance = getRootHostContainer();
1408 const instance = createInstance(
packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js
+2
@@ -45,6 +45,8 @@ export const hydrateActivityInstance = shim;
45 export const hydrateSuspenseInstance = shim;
46 export const getNextHydratableInstanceAfterActivityInstance = shim;
47 export const getNextHydratableInstanceAfterSuspenseInstance = shim;
48 +export const finalizeHydratedChildren = shim;
49 +export const commitHydratedInstance = shim;
50 export const commitHydratedContainer = shim;
51 export const commitHydratedActivityInstance = shim;
52 export const commitHydratedSuspenseInstance = shim;
packages/react-reconciler/src/ReactFiberFlags.js
+1
@@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000000100000000000
42 // It's OK to reuse these bits because these flags are mutually exclusive for
43 // different fiber types. We should really be doing this for as many flags as
44 // possible, because we're about to run out of bits.
45 +export const Hydrate = Callback;
46 export const ScheduleRetry = StoreConsistency;
47 export const ShouldSuspendCommit = Visibility;
48 export const ViewTransitionNamedMount = ShouldSuspendCommit;
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2
@@ -221,11 +221,13 @@ export const getNextHydratableInstanceAfterActivityInstance =
221 $$$config.getNextHydratableInstanceAfterActivityInstance;
222 export const getNextHydratableInstanceAfterSuspenseInstance =
223 $$$config.getNextHydratableInstanceAfterSuspenseInstance;
224 +export const commitHydratedInstance = $$$config.commitHydratedInstance;
225 export const commitHydratedContainer = $$$config.commitHydratedContainer;
226 export const commitHydratedActivityInstance =
227 $$$config.commitHydratedActivityInstance;
228 export const commitHydratedSuspenseInstance =
229 $$$config.commitHydratedSuspenseInstance;
230 +export const finalizeHydratedChildren = $$$config.finalizeHydratedChildren;
231 export const clearActivityBoundary = $$$config.clearActivityBoundary;
232 export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
233 export const clearActivityBoundaryFromContainer =
packages/shared/ReactFeatureFlags.js
+2
@@ -100,6 +100,8 @@ export const enableSuspenseyImages = false;
100
101 export const enableSrcObject = __EXPERIMENTAL__;
102
103 +export const enableHydrationChangeEvent = __EXPERIMENTAL__;
104 +
105 /**
106 * Switches Fiber creation to a simple object instead of a constructor.
107 */
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -84,6 +84,7 @@ export const enableGestureTransition = false;
84 export const enableScrollEndPolyfill = true;
85 export const enableSuspenseyImages = false;
86 export const enableSrcObject = false;
87 +export const enableHydrationChangeEvent = true;
88 export const ownerStackLimit = 1e4;
89
90 // Flow magic to verify the exports of this file match the original version.
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -75,6 +75,7 @@ export const enableLazyPublicInstanceInFabric = false;
75 export const enableScrollEndPolyfill = true;
76 export const enableSuspenseyImages = false;
77 export const enableSrcObject = false;
78 +export const enableHydrationChangeEvent = false;
79 export const ownerStackLimit = 1e4;
80
81 export const enableFragmentRefs = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -74,6 +74,7 @@ export const enableLazyPublicInstanceInFabric = false;
74 export const enableScrollEndPolyfill = true;
75 export const enableSuspenseyImages = false;
76 export const enableSrcObject = false;
77 +export const enableHydrationChangeEvent = false;
78 export const ownerStackLimit = 1e4;
79
80 export const enableFragmentRefs = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -71,6 +71,7 @@ export const enableLazyPublicInstanceInFabric = false;
71 export const enableScrollEndPolyfill = true;
72 export const enableSuspenseyImages = false;
73 export const enableSrcObject = false;
74 +export const enableHydrationChangeEvent = false;
75 export const enableFragmentRefs = false;
76 export const ownerStackLimit = 1e4;
77
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -85,6 +85,7 @@ export const enableLazyPublicInstanceInFabric = false;
85 export const enableScrollEndPolyfill = true;
86 export const enableSuspenseyImages = false;
87 export const enableSrcObject = false;
88 +export const enableHydrationChangeEvent = false;
89
90 export const enableFragmentRefs = false;
91 export const ownerStackLimit = 1e4;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -114,6 +114,7 @@ export const enableGestureTransition = false;
114
115 export const enableSuspenseyImages = false;
116 export const enableSrcObject = false;
117 +export const enableHydrationChangeEvent = false;
118
119 export const ownerStackLimit = 1e4;
120