Skip to content

Commit 587cb8f

Browse files
authored
[Fiber] Replay onChange Events if input/textarea/select has changed before hydration (facebook#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.
1 parent 79586c7 commit 587cb8f

22 files changed

+419
-64
lines changed

packages/react-dom-bindings/src/client/ReactDOMComponent.js

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ import {
4949
} from './ReactDOMTextarea';
5050
import {setSrcObject} from './ReactDOMSrcObject';
5151
import {validateTextNesting} from './validateDOMNesting';
52-
import {track} from './inputValueTracking';
5352
import setTextContent from './setTextContent';
5453
import {
5554
createDangerousStringForStyles,
@@ -67,6 +66,7 @@ import sanitizeURL from '../shared/sanitizeURL';
6766
import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
6867

6968
import {
69+
enableHydrationChangeEvent,
7070
enableScrollEndPolyfill,
7171
enableSrcObject,
7272
enableTrustedTypesIntegration,
@@ -1187,7 +1187,6 @@ export function setInitialProperties(
11871187
name,
11881188
false,
11891189
);
1190-
track((domElement: any));
11911190
return;
11921191
}
11931192
case 'select': {
@@ -1285,7 +1284,6 @@ export function setInitialProperties(
12851284
// up necessary since we never stop tracking anymore.
12861285
validateTextareaProps(domElement, props);
12871286
initTextarea(domElement, value, defaultValue, children);
1288-
track((domElement: any));
12891287
return;
12901288
}
12911289
case 'option': {
@@ -3100,17 +3098,18 @@ export function hydrateProperties(
31003098
// option and select we don't quite do the same thing and select
31013099
// is not resilient to the DOM state changing so we don't do that here.
31023100
// 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+
}
31143113
break;
31153114
case 'option':
31163115
validateOptionProps(domElement, props);
@@ -3134,8 +3133,14 @@ export function hydrateProperties(
31343133
// TODO: Make sure we check if this is still unmounted or do any clean
31353134
// up necessary since we never stop tracking anymore.
31363135
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+
}
31393144
break;
31403145
}
31413146

packages/react-dom-bindings/src/client/ReactDOMInput.js

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
1212

1313
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
1414
import {getToStringValue, toString} from './ToStringValue';
15-
import {updateValueIfChanged} from './inputValueTracking';
15+
import {track, trackHydrated, updateValueIfChanged} from './inputValueTracking';
1616
import getActiveElement from './getActiveElement';
17-
import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
17+
import {
18+
disableInputAttributeSyncing,
19+
enableHydrationChangeEvent,
20+
} from 'shared/ReactFeatureFlags';
1821
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
1922

2023
import type {ToStringValue} from './ToStringValue';
2124
import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
25+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
2226

2327
let didWarnValueDefaultValue = false;
2428
let didWarnCheckedDefaultChecked = false;
@@ -229,6 +233,8 @@ export function initInput(
229233
// Avoid setting value attribute on submit/reset inputs as it overrides the
230234
// default value provided by the browser. See: #12872
231235
if (isButton && (value === undefined || value === null)) {
236+
// We track the value just in case it changes type later on.
237+
track((element: any));
232238
return;
233239
}
234240

@@ -239,7 +245,7 @@ export function initInput(
239245

240246
// Do not assign value if it is already set. This prevents user text input
241247
// from being lost during SSR hydration.
242-
if (!isHydrating) {
248+
if (!isHydrating || enableHydrationChangeEvent) {
243249
if (disableInputAttributeSyncing) {
244250
// When not syncing the value attribute, the value property points
245251
// directly to the React prop. Only assign it if it exists.
@@ -297,7 +303,7 @@ export function initInput(
297303
typeof checkedOrDefault !== 'symbol' &&
298304
!!checkedOrDefault;
299305

300-
if (isHydrating) {
306+
if (isHydrating && !enableHydrationChangeEvent) {
301307
// Detach .checked from .defaultChecked but leave user input alone
302308
node.checked = node.checked;
303309
} else {
@@ -335,6 +341,43 @@ export function initInput(
335341
}
336342
node.name = name;
337343
}
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+
}
338381
}
339382

340383
export function restoreControlledInputState(element: Element, props: Object) {

packages/react-dom-bindings/src/client/ReactDOMSelect.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
1212

1313
import {getToStringValue, toString} from './ToStringValue';
1414
import isArray from 'shared/isArray';
15+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
1516

1617
let didWarnValueDefaultValue;
1718

@@ -86,7 +87,7 @@ function updateOptions(
8687
} else {
8788
// Do not set `select.value` as exact behavior isn't consistent across all
8889
// browsers for all cases.
89-
const selectedValue = toString(getToStringValue((propValue: any)));
90+
const selectedValue = toString(getToStringValue(propValue));
9091
let defaultSelected = null;
9192
for (let i = 0; i < options.length; i++) {
9293
if (options[i].value === selectedValue) {
@@ -157,6 +158,59 @@ export function initSelect(
157158
}
158159
}
159160

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+
160214
export function updateSelect(
161215
element: Element,
162216
value: ?string,

packages/react-dom-bindings/src/client/ReactDOMTextarea.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
1313
import {getToStringValue, toString} from './ToStringValue';
1414
import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
1515

16+
import {track, trackHydrated} from './inputValueTracking';
17+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
18+
1619
let didWarnValDefaultVal = false;
1720

1821
/**
@@ -140,6 +143,33 @@ export function initTextarea(
140143
node.value = textContent;
141144
}
142145
}
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+
}
143173
}
144174

145175
export function restoreControlledTextareaState(

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ import {
7575
diffHydratedText,
7676
trapClickOnNonInteractiveElement,
7777
} from './ReactDOMComponent';
78+
import {hydrateInput} from './ReactDOMInput';
79+
import {hydrateTextarea} from './ReactDOMTextarea';
80+
import {hydrateSelect} from './ReactDOMSelect';
7881
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
7982
import setTextContent from './setTextContent';
8083
import {
@@ -108,6 +111,7 @@ import {
108111
enableSuspenseyImages,
109112
enableSrcObject,
110113
enableViewTransition,
114+
enableHydrationChangeEvent,
111115
} from 'shared/ReactFeatureFlags';
112116
import {
113117
HostComponent,
@@ -154,6 +158,10 @@ export type Props = {
154158
top?: null | number,
155159
is?: string,
156160
size?: number,
161+
value?: string,
162+
defaultValue?: string,
163+
checked?: boolean,
164+
defaultChecked?: boolean,
157165
multiple?: boolean,
158166
src?: string | Blob | MediaSource | MediaStream, // TODO: Response
159167
srcSet?: string,
@@ -611,6 +619,27 @@ export function finalizeInitialChildren(
611619
}
612620
}
613621

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+
614643
export function shouldSetTextContent(type: string, props: Props): boolean {
615644
return (
616645
type === 'textarea' ||
@@ -819,6 +848,49 @@ export function commitMount(
819848
}
820849
}
821850

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+
822894
export function commitUpdate(
823895
domElement: Instance,
824896
type: string,

0 commit comments

Comments
 (0)