Skip to content

Commit 3c829ce

Browse files
committed
Track the initial expected value during hydration
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.
1 parent 99c4930 commit 3c829ce

File tree

6 files changed

+217
-26
lines changed

6 files changed

+217
-26
lines changed

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import sanitizeURL from '../shared/sanitizeURL';
6666
import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
6767

6868
import {
69+
enableHydrationChangeEvent,
6970
enableScrollEndPolyfill,
7071
enableSrcObject,
7172
enableTrustedTypesIntegration,
@@ -3097,16 +3098,18 @@ export function hydrateProperties(
30973098
// option and select we don't quite do the same thing and select
30983099
// is not resilient to the DOM state changing so we don't do that here.
30993100
// TODO: Consider not doing this for input and textarea.
3100-
initInput(
3101-
domElement,
3102-
props.value,
3103-
props.defaultValue,
3104-
props.checked,
3105-
props.defaultChecked,
3106-
props.type,
3107-
props.name,
3108-
true,
3109-
);
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+
}
31103113
break;
31113114
case 'option':
31123115
validateOptionProps(domElement, props);
@@ -3130,7 +3133,14 @@ export function hydrateProperties(
31303133
// TODO: Make sure we check if this is still unmounted or do any clean
31313134
// up necessary since we never stop tracking anymore.
31323135
validateTextareaProps(domElement, props);
3133-
initTextarea(domElement, props.value, props.defaultValue, props.children);
3136+
if (!enableHydrationChangeEvent) {
3137+
initTextarea(
3138+
domElement,
3139+
props.value,
3140+
props.defaultValue,
3141+
props.children,
3142+
);
3143+
}
31343144
break;
31353145
}
31363146

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

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

1313
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
1414
import {getToStringValue, toString} from './ToStringValue';
15-
import {track, 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';
@@ -241,7 +244,7 @@ export function initInput(
241244

242245
// Do not assign value if it is already set. This prevents user text input
243246
// from being lost during SSR hydration.
244-
if (!isHydrating) {
247+
if (!isHydrating || enableHydrationChangeEvent) {
245248
if (disableInputAttributeSyncing) {
246249
// When not syncing the value attribute, the value property points
247250
// directly to the React prop. Only assign it if it exists.
@@ -299,7 +302,7 @@ export function initInput(
299302
typeof checkedOrDefault !== 'symbol' &&
300303
!!checkedOrDefault;
301304

302-
if (isHydrating) {
305+
if (isHydrating && !enableHydrationChangeEvent) {
303306
// Detach .checked from .defaultChecked but leave user input alone
304307
node.checked = node.checked;
305308
} else {
@@ -340,6 +343,39 @@ export function initInput(
340343
track((element: any));
341344
}
342345

346+
export function hydrateInput(
347+
element: Element,
348+
value: ?string,
349+
defaultValue: ?string,
350+
checked: ?boolean,
351+
defaultChecked: ?boolean,
352+
): void {
353+
const node: HTMLInputElement = (element: any);
354+
355+
const defaultValueStr =
356+
defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
357+
const initialValue =
358+
value != null ? toString(getToStringValue(value)) : defaultValueStr;
359+
360+
const checkedOrDefault = checked != null ? checked : defaultChecked;
361+
// TODO: This 'function' or 'symbol' check isn't replicated in other places
362+
// so this semantic is inconsistent.
363+
const initialChecked =
364+
typeof checkedOrDefault !== 'function' &&
365+
typeof checkedOrDefault !== 'symbol' &&
366+
!!checkedOrDefault;
367+
368+
// Detach .checked from .defaultChecked but leave user input alone
369+
node.checked = node.checked;
370+
371+
const changed = trackHydrated((node: any), initialValue, initialChecked);
372+
if (changed) {
373+
// If the current value is different, that suggests that the user
374+
// changed it before hydration.
375+
// TODO: Queue replay.
376+
}
377+
}
378+
343379
export function restoreControlledInputState(element: Element, props: Object) {
344380
const rootNode: HTMLInputElement = (element: any);
345381
updateInput(

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function updateOptions(
8686
} else {
8787
// Do not set `select.value` as exact behavior isn't consistent across all
8888
// browsers for all cases.
89-
const selectedValue = toString(getToStringValue((propValue: any)));
89+
const selectedValue = toString(getToStringValue(propValue));
9090
let defaultSelected = null;
9191
for (let i = 0; i < options.length; i++) {
9292
if (options[i].value === selectedValue) {
@@ -157,6 +157,59 @@ export function initSelect(
157157
}
158158
}
159159

160+
export function hydrateSelect(
161+
element: Element,
162+
value: ?string,
163+
defaultValue: ?string,
164+
multiple: ?boolean,
165+
): void {
166+
const node: HTMLSelectElement = (element: any);
167+
const options: HTMLOptionsCollection = node.options;
168+
169+
const propValue: any = value != null ? value : defaultValue;
170+
171+
let changed = false;
172+
173+
if (multiple) {
174+
const selectedValues = (propValue: ?Array<string>);
175+
const selectedValue: {[string]: boolean} = {};
176+
if (selectedValues != null) {
177+
for (let i = 0; i < selectedValues.length; i++) {
178+
// Prefix to avoid chaos with special keys.
179+
selectedValue['$' + selectedValues[i]] = true;
180+
}
181+
}
182+
for (let i = 0; i < options.length; i++) {
183+
const expectedSelected = selectedValue.hasOwnProperty(
184+
'$' + options[i].value,
185+
);
186+
if (options[i].selected !== expectedSelected) {
187+
changed = true;
188+
break;
189+
}
190+
}
191+
} else {
192+
let selectedValue =
193+
propValue == null ? null : toString(getToStringValue(propValue));
194+
for (let i = 0; i < options.length; i++) {
195+
if (selectedValue == null && !options[i].disabled) {
196+
// We expect the first non-disabled option to be selected if the selected is null.
197+
selectedValue = options[i].value;
198+
}
199+
const expectedSelected = options[i].value === selectedValue;
200+
if (options[i].selected !== expectedSelected) {
201+
changed = true;
202+
break;
203+
}
204+
}
205+
}
206+
if (changed) {
207+
// If the current selection is different than our initial that suggests that the user
208+
// changed it before hydration.
209+
// TODO: Queue replay.
210+
}
211+
}
212+
160213
export function updateSelect(
161214
element: Element,
162215
value: ?string,

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

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

16-
import {track} from './inputValueTracking';
16+
import {track, trackHydrated} from './inputValueTracking';
1717

1818
let didWarnValDefaultVal = false;
1919

@@ -146,6 +146,31 @@ export function initTextarea(
146146
track((element: any));
147147
}
148148

149+
export function hydrateTextarea(
150+
element: Element,
151+
value: ?string,
152+
defaultValue: ?string,
153+
): void {
154+
const node: HTMLTextAreaElement = (element: any);
155+
let initialValue = value;
156+
if (initialValue == null) {
157+
if (defaultValue == null) {
158+
defaultValue = '';
159+
}
160+
initialValue = defaultValue;
161+
}
162+
// Track the value that we last observed which is the hydrated value so
163+
// that any change event that fires will trigger onChange on the actual
164+
// current value.
165+
const stringValue = toString(getToStringValue(initialValue));
166+
const changed = trackHydrated((node: any), stringValue, false);
167+
if (changed) {
168+
// If the current value is different, that suggests that the user
169+
// changed it before hydration.
170+
// TODO: Queue replay.
171+
}
172+
}
173+
149174
export function restoreControlledTextareaState(
150175
element: Element,
151176
props: Object,

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

Lines changed: 38 additions & 1 deletion
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 {
@@ -155,6 +158,10 @@ export type Props = {
155158
top?: null | number,
156159
is?: string,
157160
size?: number,
161+
value?: string,
162+
defaultValue?: string,
163+
checked?: boolean,
164+
defaultChecked?: boolean,
158165
multiple?: boolean,
159166
src?: string | Blob | MediaSource | MediaStream, // TODO: Response
160167
srcSet?: string,
@@ -844,14 +851,44 @@ export function commitMount(
844851
export function commitHydratedInstance(
845852
domElement: Instance,
846853
type: string,
847-
newProps: Props,
854+
props: Props,
848855
internalInstanceHandle: Object,
849856
): void {
857+
if (!enableHydrationChangeEvent) {
858+
return;
859+
}
850860
// This fires in the commit phase if a hydrated instance needs to do further
851861
// work in the commit phase. Similar to commitMount. However, this should not
852862
// do things that would've already happened such as set auto focus since that
853863
// would steal focus. It's only scheduled if finalizeHydratedChildren returns
854864
// 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+
}
855892
}
856893

857894
export function commitUpdate(

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

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,16 @@ function getValueFromNode(node: HTMLInputElement): string {
5151
return value;
5252
}
5353

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 {
5659
const descriptor = Object.getOwnPropertyDescriptor(
5760
node.constructor.prototype,
5861
valueField,
5962
);
6063

61-
if (__DEV__) {
62-
checkFormFieldValueStringCoercion(node[valueField]);
63-
}
64-
let currentValue = '' + node[valueField];
65-
6664
// if someone has already defined a value or Safari, then bail
6765
// and don't track value will cause over reporting of changes,
6866
// but it's better then a hard failure
@@ -123,7 +121,39 @@ export function track(node: ElementWithValueTracker) {
123121
return;
124122
}
125123

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;
127157
}
128158

129159
export function updateValueIfChanged(node: ElementWithValueTracker): boolean {

0 commit comments

Comments
 (0)