mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 00:20:04 +01:00
[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.
This commit is contained in:
parent
79586c7eb6
commit
587cb8f896
|
|
@ -49,7 +49,6 @@ import {
|
|||
} from './ReactDOMTextarea';
|
||||
import {setSrcObject} from './ReactDOMSrcObject';
|
||||
import {validateTextNesting} from './validateDOMNesting';
|
||||
import {track} from './inputValueTracking';
|
||||
import setTextContent from './setTextContent';
|
||||
import {
|
||||
createDangerousStringForStyles,
|
||||
|
|
@ -67,6 +66,7 @@ import sanitizeURL from '../shared/sanitizeURL';
|
|||
import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
|
||||
|
||||
import {
|
||||
enableHydrationChangeEvent,
|
||||
enableScrollEndPolyfill,
|
||||
enableSrcObject,
|
||||
enableTrustedTypesIntegration,
|
||||
|
|
@ -1187,7 +1187,6 @@ export function setInitialProperties(
|
|||
name,
|
||||
false,
|
||||
);
|
||||
track((domElement: any));
|
||||
return;
|
||||
}
|
||||
case 'select': {
|
||||
|
|
@ -1285,7 +1284,6 @@ export function setInitialProperties(
|
|||
// up necessary since we never stop tracking anymore.
|
||||
validateTextareaProps(domElement, props);
|
||||
initTextarea(domElement, value, defaultValue, children);
|
||||
track((domElement: any));
|
||||
return;
|
||||
}
|
||||
case 'option': {
|
||||
|
|
@ -3100,17 +3098,18 @@ export function hydrateProperties(
|
|||
// option and select we don't quite do the same thing and select
|
||||
// is not resilient to the DOM state changing so we don't do that here.
|
||||
// TODO: Consider not doing this for input and textarea.
|
||||
initInput(
|
||||
domElement,
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.checked,
|
||||
props.defaultChecked,
|
||||
props.type,
|
||||
props.name,
|
||||
true,
|
||||
);
|
||||
track((domElement: any));
|
||||
if (!enableHydrationChangeEvent) {
|
||||
initInput(
|
||||
domElement,
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.checked,
|
||||
props.defaultChecked,
|
||||
props.type,
|
||||
props.name,
|
||||
true,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'option':
|
||||
validateOptionProps(domElement, props);
|
||||
|
|
@ -3134,8 +3133,14 @@ export function hydrateProperties(
|
|||
// TODO: Make sure we check if this is still unmounted or do any clean
|
||||
// up necessary since we never stop tracking anymore.
|
||||
validateTextareaProps(domElement, props);
|
||||
initTextarea(domElement, props.value, props.defaultValue, props.children);
|
||||
track((domElement: any));
|
||||
if (!enableHydrationChangeEvent) {
|
||||
initTextarea(
|
||||
domElement,
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.children,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,17 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
|
|||
|
||||
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
|
||||
import {getToStringValue, toString} from './ToStringValue';
|
||||
import {updateValueIfChanged} from './inputValueTracking';
|
||||
import {track, trackHydrated, updateValueIfChanged} from './inputValueTracking';
|
||||
import getActiveElement from './getActiveElement';
|
||||
import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
disableInputAttributeSyncing,
|
||||
enableHydrationChangeEvent,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
|
||||
|
||||
import type {ToStringValue} from './ToStringValue';
|
||||
import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
|
||||
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
|
||||
|
||||
let didWarnValueDefaultValue = false;
|
||||
let didWarnCheckedDefaultChecked = false;
|
||||
|
|
@ -229,6 +233,8 @@ export function initInput(
|
|||
// Avoid setting value attribute on submit/reset inputs as it overrides the
|
||||
// default value provided by the browser. See: #12872
|
||||
if (isButton && (value === undefined || value === null)) {
|
||||
// We track the value just in case it changes type later on.
|
||||
track((element: any));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +245,7 @@ export function initInput(
|
|||
|
||||
// Do not assign value if it is already set. This prevents user text input
|
||||
// from being lost during SSR hydration.
|
||||
if (!isHydrating) {
|
||||
if (!isHydrating || enableHydrationChangeEvent) {
|
||||
if (disableInputAttributeSyncing) {
|
||||
// When not syncing the value attribute, the value property points
|
||||
// directly to the React prop. Only assign it if it exists.
|
||||
|
|
@ -297,7 +303,7 @@ export function initInput(
|
|||
typeof checkedOrDefault !== 'symbol' &&
|
||||
!!checkedOrDefault;
|
||||
|
||||
if (isHydrating) {
|
||||
if (isHydrating && !enableHydrationChangeEvent) {
|
||||
// Detach .checked from .defaultChecked but leave user input alone
|
||||
node.checked = node.checked;
|
||||
} else {
|
||||
|
|
@ -335,6 +341,43 @@ export function initInput(
|
|||
}
|
||||
node.name = name;
|
||||
}
|
||||
track((element: any));
|
||||
}
|
||||
|
||||
export function hydrateInput(
|
||||
element: Element,
|
||||
value: ?string,
|
||||
defaultValue: ?string,
|
||||
checked: ?boolean,
|
||||
defaultChecked: ?boolean,
|
||||
): void {
|
||||
const node: HTMLInputElement = (element: any);
|
||||
|
||||
const defaultValueStr =
|
||||
defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
|
||||
const initialValue =
|
||||
value != null ? toString(getToStringValue(value)) : defaultValueStr;
|
||||
|
||||
const checkedOrDefault = checked != null ? checked : defaultChecked;
|
||||
// TODO: This 'function' or 'symbol' check isn't replicated in other places
|
||||
// so this semantic is inconsistent.
|
||||
const initialChecked =
|
||||
typeof checkedOrDefault !== 'function' &&
|
||||
typeof checkedOrDefault !== 'symbol' &&
|
||||
!!checkedOrDefault;
|
||||
|
||||
// Detach .checked from .defaultChecked but leave user input alone
|
||||
node.checked = node.checked;
|
||||
|
||||
const changed = trackHydrated((node: any), initialValue, initialChecked);
|
||||
if (changed) {
|
||||
// If the current value is different, that suggests that the user
|
||||
// changed it before hydration. Queue a replay of the change event.
|
||||
// For radio buttons the change event only fires on the selected one.
|
||||
if (node.type !== 'radio' || node.checked) {
|
||||
queueChangeEvent(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreControlledInputState(element: Element, props: Object) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
|
|||
|
||||
import {getToStringValue, toString} from './ToStringValue';
|
||||
import isArray from 'shared/isArray';
|
||||
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
|
||||
|
||||
let didWarnValueDefaultValue;
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ function updateOptions(
|
|||
} else {
|
||||
// Do not set `select.value` as exact behavior isn't consistent across all
|
||||
// browsers for all cases.
|
||||
const selectedValue = toString(getToStringValue((propValue: any)));
|
||||
const selectedValue = toString(getToStringValue(propValue));
|
||||
let defaultSelected = null;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (options[i].value === selectedValue) {
|
||||
|
|
@ -157,6 +158,59 @@ export function initSelect(
|
|||
}
|
||||
}
|
||||
|
||||
export function hydrateSelect(
|
||||
element: Element,
|
||||
value: ?string,
|
||||
defaultValue: ?string,
|
||||
multiple: ?boolean,
|
||||
): void {
|
||||
const node: HTMLSelectElement = (element: any);
|
||||
const options: HTMLOptionsCollection = node.options;
|
||||
|
||||
const propValue: any = value != null ? value : defaultValue;
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (multiple) {
|
||||
const selectedValues = (propValue: ?Array<string>);
|
||||
const selectedValue: {[string]: boolean} = {};
|
||||
if (selectedValues != null) {
|
||||
for (let i = 0; i < selectedValues.length; i++) {
|
||||
// Prefix to avoid chaos with special keys.
|
||||
selectedValue['$' + selectedValues[i]] = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const expectedSelected = selectedValue.hasOwnProperty(
|
||||
'$' + options[i].value,
|
||||
);
|
||||
if (options[i].selected !== expectedSelected) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let selectedValue =
|
||||
propValue == null ? null : toString(getToStringValue(propValue));
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (selectedValue == null && !options[i].disabled) {
|
||||
// We expect the first non-disabled option to be selected if the selected is null.
|
||||
selectedValue = options[i].value;
|
||||
}
|
||||
const expectedSelected = options[i].value === selectedValue;
|
||||
if (options[i].selected !== expectedSelected) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
// If the current selection is different than our initial that suggests that the user
|
||||
// changed it before hydration. Queue a replay of the change event.
|
||||
queueChangeEvent(node);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSelect(
|
||||
element: Element,
|
||||
value: ?string,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
|
|||
import {getToStringValue, toString} from './ToStringValue';
|
||||
import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
|
||||
|
||||
import {track, trackHydrated} from './inputValueTracking';
|
||||
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
|
||||
|
||||
let didWarnValDefaultVal = false;
|
||||
|
||||
/**
|
||||
|
|
@ -140,6 +143,33 @@ export function initTextarea(
|
|||
node.value = textContent;
|
||||
}
|
||||
}
|
||||
|
||||
track((element: any));
|
||||
}
|
||||
|
||||
export function hydrateTextarea(
|
||||
element: Element,
|
||||
value: ?string,
|
||||
defaultValue: ?string,
|
||||
): void {
|
||||
const node: HTMLTextAreaElement = (element: any);
|
||||
let initialValue = value;
|
||||
if (initialValue == null) {
|
||||
if (defaultValue == null) {
|
||||
defaultValue = '';
|
||||
}
|
||||
initialValue = defaultValue;
|
||||
}
|
||||
// Track the value that we last observed which is the hydrated value so
|
||||
// that any change event that fires will trigger onChange on the actual
|
||||
// current value.
|
||||
const stringValue = toString(getToStringValue(initialValue));
|
||||
const changed = trackHydrated((node: any), stringValue, false);
|
||||
if (changed) {
|
||||
// If the current value is different, that suggests that the user
|
||||
// changed it before hydration. Queue a replay of the change event.
|
||||
queueChangeEvent(node);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreControlledTextareaState(
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ import {
|
|||
diffHydratedText,
|
||||
trapClickOnNonInteractiveElement,
|
||||
} from './ReactDOMComponent';
|
||||
import {hydrateInput} from './ReactDOMInput';
|
||||
import {hydrateTextarea} from './ReactDOMTextarea';
|
||||
import {hydrateSelect} from './ReactDOMSelect';
|
||||
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
|
||||
import setTextContent from './setTextContent';
|
||||
import {
|
||||
|
|
@ -108,6 +111,7 @@ import {
|
|||
enableSuspenseyImages,
|
||||
enableSrcObject,
|
||||
enableViewTransition,
|
||||
enableHydrationChangeEvent,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
HostComponent,
|
||||
|
|
@ -154,6 +158,10 @@ export type Props = {
|
|||
top?: null | number,
|
||||
is?: string,
|
||||
size?: number,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
checked?: boolean,
|
||||
defaultChecked?: boolean,
|
||||
multiple?: boolean,
|
||||
src?: string | Blob | MediaSource | MediaStream, // TODO: Response
|
||||
srcSet?: string,
|
||||
|
|
@ -611,6 +619,27 @@ export function finalizeInitialChildren(
|
|||
}
|
||||
}
|
||||
|
||||
export function finalizeHydratedChildren(
|
||||
domElement: Instance,
|
||||
type: string,
|
||||
props: Props,
|
||||
hostContext: HostContext,
|
||||
): boolean {
|
||||
// TOOD: Consider unifying this with hydrateInstance.
|
||||
if (!enableHydrationChangeEvent) {
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case 'input':
|
||||
case 'select':
|
||||
case 'textarea':
|
||||
case 'img':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSetTextContent(type: string, props: Props): boolean {
|
||||
return (
|
||||
type === 'textarea' ||
|
||||
|
|
@ -819,6 +848,49 @@ export function commitMount(
|
|||
}
|
||||
}
|
||||
|
||||
export function commitHydratedInstance(
|
||||
domElement: Instance,
|
||||
type: string,
|
||||
props: Props,
|
||||
internalInstanceHandle: Object,
|
||||
): void {
|
||||
if (!enableHydrationChangeEvent) {
|
||||
return;
|
||||
}
|
||||
// This fires in the commit phase if a hydrated instance needs to do further
|
||||
// work in the commit phase. Similar to commitMount. However, this should not
|
||||
// do things that would've already happened such as set auto focus since that
|
||||
// would steal focus. It's only scheduled if finalizeHydratedChildren returns
|
||||
// true.
|
||||
switch (type) {
|
||||
case 'input': {
|
||||
hydrateInput(
|
||||
domElement,
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.checked,
|
||||
props.defaultChecked,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'select': {
|
||||
hydrateSelect(
|
||||
domElement,
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.multiple,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'textarea':
|
||||
hydrateTextarea(domElement, props.value, props.defaultValue);
|
||||
break;
|
||||
case 'img':
|
||||
// TODO: Should we replay onLoad events?
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function commitUpdate(
|
||||
domElement: Instance,
|
||||
type: string,
|
||||
|
|
|
|||
|
|
@ -51,18 +51,16 @@ function getValueFromNode(node: HTMLInputElement): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function trackValueOnNode(node: any): ?ValueTracker {
|
||||
const valueField = isCheckable(node) ? 'checked' : 'value';
|
||||
function trackValueOnNode(
|
||||
node: any,
|
||||
valueField: 'checked' | 'value',
|
||||
currentValue: string,
|
||||
): ?ValueTracker {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
node.constructor.prototype,
|
||||
valueField,
|
||||
);
|
||||
|
||||
if (__DEV__) {
|
||||
checkFormFieldValueStringCoercion(node[valueField]);
|
||||
}
|
||||
let currentValue = '' + node[valueField];
|
||||
|
||||
// if someone has already defined a value or Safari, then bail
|
||||
// and don't track value will cause over reporting of changes,
|
||||
// but it's better then a hard failure
|
||||
|
|
@ -123,7 +121,39 @@ export function track(node: ElementWithValueTracker) {
|
|||
return;
|
||||
}
|
||||
|
||||
node._valueTracker = trackValueOnNode(node);
|
||||
const valueField = isCheckable(node) ? 'checked' : 'value';
|
||||
// This is read from the DOM so always safe to coerce. We really shouldn't
|
||||
// be coercing to a string at all. It's just historical.
|
||||
// eslint-disable-next-line react-internal/safe-string-coercion
|
||||
const initialValue = '' + (node[valueField]: any);
|
||||
node._valueTracker = trackValueOnNode(node, valueField, initialValue);
|
||||
}
|
||||
|
||||
export function trackHydrated(
|
||||
node: ElementWithValueTracker,
|
||||
initialValue: string,
|
||||
initialChecked: boolean,
|
||||
): boolean {
|
||||
// For hydration, the initial value is not the current value but the value
|
||||
// that we last observed which is what the initial server render was.
|
||||
if (getTracker(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let valueField;
|
||||
let expectedValue;
|
||||
if (isCheckable(node)) {
|
||||
valueField = 'checked';
|
||||
// eslint-disable-next-line react-internal/safe-string-coercion
|
||||
expectedValue = '' + (initialChecked: any);
|
||||
} else {
|
||||
valueField = 'value';
|
||||
expectedValue = initialValue;
|
||||
}
|
||||
// eslint-disable-next-line react-internal/safe-string-coercion
|
||||
const currentValue = '' + (node[valueField]: any);
|
||||
node._valueTracker = trackValueOnNode(node, valueField, expectedValue);
|
||||
return currentValue !== expectedValue;
|
||||
}
|
||||
|
||||
export function updateValueIfChanged(node: ElementWithValueTracker): boolean {
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ import {
|
|||
attemptHydrationAtCurrentPriority,
|
||||
} from 'react-reconciler/src/ReactFiberReconciler';
|
||||
|
||||
import {enableHydrationChangeEvent} from 'shared/ReactFeatureFlags';
|
||||
|
||||
// TODO: Upgrade this definition once we're on a newer version of Flow that
|
||||
// has this definition built-in.
|
||||
type PointerEvent = Event & {
|
||||
type PointerEventType = Event & {
|
||||
pointerId: number,
|
||||
relatedTarget: EventTarget | null,
|
||||
...
|
||||
|
|
@ -84,6 +86,8 @@ const queuedPointers: Map<number, QueuedReplayableEvent> = new Map();
|
|||
const queuedPointerCaptures: Map<number, QueuedReplayableEvent> = new Map();
|
||||
// We could consider replaying selectionchange and touchmoves too.
|
||||
|
||||
const queuedChangeEventTargets: Array<EventTarget> = [];
|
||||
|
||||
type QueuedHydrationTarget = {
|
||||
blockedOn: null | Container | ActivityInstance | SuspenseInstance,
|
||||
target: Node,
|
||||
|
|
@ -164,13 +168,13 @@ export function clearIfContinuousEvent(
|
|||
break;
|
||||
case 'pointerover':
|
||||
case 'pointerout': {
|
||||
const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
|
||||
const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
|
||||
queuedPointers.delete(pointerId);
|
||||
break;
|
||||
}
|
||||
case 'gotpointercapture':
|
||||
case 'lostpointercapture': {
|
||||
const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
|
||||
const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
|
||||
queuedPointerCaptures.delete(pointerId);
|
||||
break;
|
||||
}
|
||||
|
|
@ -268,7 +272,7 @@ export function queueIfContinuousEvent(
|
|||
return true;
|
||||
}
|
||||
case 'pointerover': {
|
||||
const pointerEvent = ((nativeEvent: any): PointerEvent);
|
||||
const pointerEvent = ((nativeEvent: any): PointerEventType);
|
||||
const pointerId = pointerEvent.pointerId;
|
||||
queuedPointers.set(
|
||||
pointerId,
|
||||
|
|
@ -284,7 +288,7 @@ export function queueIfContinuousEvent(
|
|||
return true;
|
||||
}
|
||||
case 'gotpointercapture': {
|
||||
const pointerEvent = ((nativeEvent: any): PointerEvent);
|
||||
const pointerEvent = ((nativeEvent: any): PointerEventType);
|
||||
const pointerId = pointerEvent.pointerId;
|
||||
queuedPointerCaptures.set(
|
||||
pointerId,
|
||||
|
|
@ -421,6 +425,31 @@ function attemptReplayContinuousQueuedEventInMap(
|
|||
}
|
||||
}
|
||||
|
||||
function replayChangeEvent(target: EventTarget): void {
|
||||
// Dispatch a fake "change" event for the input.
|
||||
const element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =
|
||||
(target: any);
|
||||
if (element.nodeName === 'INPUT') {
|
||||
if (element.type === 'checkbox' || element.type === 'radio') {
|
||||
// Checkboxes always fire a click event regardless of how the change was made.
|
||||
const EventCtr =
|
||||
typeof PointerEvent === 'function' ? PointerEvent : Event;
|
||||
target.dispatchEvent(new EventCtr('click', {bubbles: true}));
|
||||
// For checkboxes the input event uses the Event constructor instead of InputEvent.
|
||||
target.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
} else {
|
||||
if (typeof InputEvent === 'function') {
|
||||
target.dispatchEvent(new InputEvent('input', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
} else if (element.nodeName === 'TEXTAREA') {
|
||||
if (typeof InputEvent === 'function') {
|
||||
target.dispatchEvent(new InputEvent('input', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
target.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
|
||||
function replayUnblockedEvents() {
|
||||
hasScheduledReplayAttempt = false;
|
||||
// Replay any continuous events.
|
||||
|
|
@ -435,6 +464,22 @@ function replayUnblockedEvents() {
|
|||
}
|
||||
queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
|
||||
queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
|
||||
if (enableHydrationChangeEvent) {
|
||||
for (let i = 0; i < queuedChangeEventTargets.length; i++) {
|
||||
replayChangeEvent(queuedChangeEventTargets[i]);
|
||||
}
|
||||
queuedChangeEventTargets.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function queueChangeEvent(target: EventTarget): void {
|
||||
if (enableHydrationChangeEvent) {
|
||||
queuedChangeEventTargets.push(target);
|
||||
if (!hasScheduledReplayAttempt) {
|
||||
hasScheduledReplayAttempt = true;
|
||||
scheduleCallback(NormalPriority, replayUnblockedEvents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCallbackIfUnblocked(
|
||||
|
|
|
|||
|
|
@ -1536,10 +1536,14 @@ describe('ReactDOMInput', () => {
|
|||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
|
||||
// Currently, we don't fire onChange when hydrating
|
||||
assertLog([]);
|
||||
// Strangely, we leave `b` checked even though we rendered A with
|
||||
// checked={true} and B with checked={false}. Arguably this is a bug.
|
||||
if (gate(flags => flags.enableHydrationChangeEvent)) {
|
||||
// We replayed the click since the value changed before hydration.
|
||||
assertLog(['click b']);
|
||||
} else {
|
||||
assertLog([]);
|
||||
// Strangely, we leave `b` checked even though we rendered A with
|
||||
// checked={true} and B with checked={false}. Arguably this is a bug.
|
||||
}
|
||||
expect(a.checked).toBe(false);
|
||||
expect(b.checked).toBe(true);
|
||||
expect(c.checked).toBe(false);
|
||||
|
|
@ -1554,22 +1558,35 @@ describe('ReactDOMInput', () => {
|
|||
dispatchEventOnNode(c, 'click');
|
||||
});
|
||||
|
||||
// then since C's onClick doesn't set state, A becomes rechecked.
|
||||
assertLog(['click c']);
|
||||
expect(a.checked).toBe(true);
|
||||
expect(b.checked).toBe(false);
|
||||
expect(c.checked).toBe(false);
|
||||
if (gate(flags => flags.enableHydrationChangeEvent)) {
|
||||
// then since C's onClick doesn't set state, B becomes rechecked.
|
||||
expect(a.checked).toBe(false);
|
||||
expect(b.checked).toBe(true);
|
||||
expect(c.checked).toBe(false);
|
||||
} else {
|
||||
// then since C's onClick doesn't set state, A becomes rechecked
|
||||
// since in this branch we didn't replay to select B.
|
||||
expect(a.checked).toBe(true);
|
||||
expect(b.checked).toBe(false);
|
||||
expect(c.checked).toBe(false);
|
||||
}
|
||||
expect(isCheckedDirty(a)).toBe(true);
|
||||
expect(isCheckedDirty(b)).toBe(true);
|
||||
expect(isCheckedDirty(c)).toBe(true);
|
||||
assertInputTrackingIsCurrent(container);
|
||||
|
||||
// And we can also change to B properly after hydration.
|
||||
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.
|
||||
assertLog([]);
|
||||
} else {
|
||||
// And we can also change to B properly after hydration.
|
||||
assertLog(['click b']);
|
||||
}
|
||||
expect(a.checked).toBe(false);
|
||||
expect(b.checked).toBe(true);
|
||||
expect(c.checked).toBe(false);
|
||||
|
|
@ -1628,8 +1645,12 @@ describe('ReactDOMInput', () => {
|
|||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
|
||||
// Currently, we don't fire onChange when hydrating
|
||||
assertLog([]);
|
||||
if (gate(flags => flags.enableHydrationChangeEvent)) {
|
||||
// We replayed the click since the value changed before hydration.
|
||||
assertLog(['click b']);
|
||||
} else {
|
||||
assertLog([]);
|
||||
}
|
||||
expect(a.checked).toBe(false);
|
||||
expect(b.checked).toBe(true);
|
||||
expect(c.checked).toBe(false);
|
||||
|
|
|
|||
|
|
@ -278,10 +278,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
|
|||
await testUserInteractionBeforeClientRender(
|
||||
<ControlledInput onChange={() => changeCount++} />,
|
||||
);
|
||||
// note that there's a strong argument to be made that the DOM revival
|
||||
// algorithm should notice that the user has changed the value and fire
|
||||
// an onChange. however, it does not now, so that's what this tests.
|
||||
expect(changeCount).toBe(0);
|
||||
expect(changeCount).toBe(
|
||||
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not blow away user-interaction on successful reconnect to an uncontrolled range input', () =>
|
||||
|
|
@ -302,7 +301,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
|
|||
'0.25',
|
||||
'1',
|
||||
);
|
||||
expect(changeCount).toBe(0);
|
||||
expect(changeCount).toBe(
|
||||
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not blow away user-entered text on successful reconnect to an uncontrolled checkbox', () =>
|
||||
|
|
@ -321,24 +322,22 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
|
|||
false,
|
||||
'checked',
|
||||
);
|
||||
expect(changeCount).toBe(0);
|
||||
expect(changeCount).toBe(
|
||||
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
|
||||
);
|
||||
});
|
||||
|
||||
// skipping this test because React 15 does the wrong thing. it blows
|
||||
// away the user's typing in the textarea.
|
||||
// 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', () =>
|
||||
testUserInteractionBeforeClientRender(<textarea defaultValue="Hello" />));
|
||||
|
||||
// skipping this test because React 15 does the wrong thing. it blows
|
||||
// away the user's typing in the textarea.
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
|
||||
// @gate enableHydrationChangeEvent
|
||||
it('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
|
||||
let changeCount = 0;
|
||||
await testUserInteractionBeforeClientRender(
|
||||
<ControlledTextArea onChange={() => changeCount++} />,
|
||||
);
|
||||
expect(changeCount).toBe(0);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should not blow away user-selected value on successful reconnect to an uncontrolled select', () =>
|
||||
|
|
@ -358,7 +357,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
|
|||
await testUserInteractionBeforeClientRender(
|
||||
<ControlledSelect onChange={() => changeCount++} />,
|
||||
);
|
||||
expect(changeCount).toBe(0);
|
||||
expect(changeCount).toBe(
|
||||
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
unhideDehydratedBoundary,
|
||||
unhideInstance,
|
||||
unhideTextInstance,
|
||||
commitHydratedInstance,
|
||||
commitHydratedContainer,
|
||||
commitHydratedActivityInstance,
|
||||
commitHydratedSuspenseInstance,
|
||||
|
|
@ -87,6 +88,28 @@ export function commitHostMount(finishedWork: Fiber) {
|
|||
}
|
||||
}
|
||||
|
||||
export function commitHostHydratedInstance(finishedWork: Fiber) {
|
||||
const type = finishedWork.type;
|
||||
const props = finishedWork.memoizedProps;
|
||||
const instance: Instance = finishedWork.stateNode;
|
||||
try {
|
||||
if (__DEV__) {
|
||||
runWithFiberInDEV(
|
||||
finishedWork,
|
||||
commitHydratedInstance,
|
||||
instance,
|
||||
type,
|
||||
props,
|
||||
finishedWork,
|
||||
);
|
||||
} else {
|
||||
commitHydratedInstance(instance, type, props, finishedWork);
|
||||
}
|
||||
} catch (error) {
|
||||
captureCommitPhaseError(finishedWork, finishedWork.return, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function commitHostUpdate(
|
||||
finishedWork: Fiber,
|
||||
newProps: any,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ import {
|
|||
ChildDeletion,
|
||||
Snapshot,
|
||||
Update,
|
||||
Hydrate,
|
||||
Callback,
|
||||
Ref,
|
||||
Hydrating,
|
||||
|
|
@ -227,6 +228,7 @@ import {
|
|||
} from './ReactFiberCommitEffects';
|
||||
import {
|
||||
commitHostMount,
|
||||
commitHostHydratedInstance,
|
||||
commitHostUpdate,
|
||||
commitHostTextUpdate,
|
||||
commitHostResetTextContent,
|
||||
|
|
@ -663,8 +665,12 @@ function commitLayoutEffectOnFiber(
|
|||
// (eg DOM renderer may schedule auto-focus for inputs and form controls).
|
||||
// These effects should only be committed when components are first mounted,
|
||||
// aka when there is no current/alternate.
|
||||
if (current === null && flags & Update) {
|
||||
commitHostMount(finishedWork);
|
||||
if (current === null) {
|
||||
if (flags & Update) {
|
||||
commitHostMount(finishedWork);
|
||||
} else if (flags & Hydrate) {
|
||||
commitHostHydratedInstance(finishedWork);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags & Ref) {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ import {
|
|||
ShouldSuspendCommit,
|
||||
Cloned,
|
||||
ViewTransitionStatic,
|
||||
Hydrate,
|
||||
} from './ReactFiberFlags';
|
||||
|
||||
import {
|
||||
|
|
@ -110,6 +111,7 @@ import {
|
|||
resolveSingletonInstance,
|
||||
appendInitialChild,
|
||||
finalizeInitialChildren,
|
||||
finalizeHydratedChildren,
|
||||
supportsMutation,
|
||||
supportsPersistence,
|
||||
supportsResources,
|
||||
|
|
@ -1391,6 +1393,16 @@ function completeWork(
|
|||
// TODO: Move this and createInstance step into the beginPhase
|
||||
// to consolidate.
|
||||
prepareToHydrateHostInstance(workInProgress, currentHostContext);
|
||||
if (
|
||||
finalizeHydratedChildren(
|
||||
workInProgress.stateNode,
|
||||
type,
|
||||
newProps,
|
||||
currentHostContext,
|
||||
)
|
||||
) {
|
||||
workInProgress.flags |= Hydrate;
|
||||
}
|
||||
} else {
|
||||
const rootContainerInstance = getRootHostContainer();
|
||||
const instance = createInstance(
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const hydrateActivityInstance = shim;
|
|||
export const hydrateSuspenseInstance = shim;
|
||||
export const getNextHydratableInstanceAfterActivityInstance = shim;
|
||||
export const getNextHydratableInstanceAfterSuspenseInstance = shim;
|
||||
export const finalizeHydratedChildren = shim;
|
||||
export const commitHydratedInstance = shim;
|
||||
export const commitHydratedContainer = shim;
|
||||
export const commitHydratedActivityInstance = shim;
|
||||
export const commitHydratedSuspenseInstance = shim;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000000100000000000
|
|||
// It's OK to reuse these bits because these flags are mutually exclusive for
|
||||
// different fiber types. We should really be doing this for as many flags as
|
||||
// possible, because we're about to run out of bits.
|
||||
export const Hydrate = Callback;
|
||||
export const ScheduleRetry = StoreConsistency;
|
||||
export const ShouldSuspendCommit = Visibility;
|
||||
export const ViewTransitionNamedMount = ShouldSuspendCommit;
|
||||
|
|
|
|||
|
|
@ -221,11 +221,13 @@ export const getNextHydratableInstanceAfterActivityInstance =
|
|||
$$$config.getNextHydratableInstanceAfterActivityInstance;
|
||||
export const getNextHydratableInstanceAfterSuspenseInstance =
|
||||
$$$config.getNextHydratableInstanceAfterSuspenseInstance;
|
||||
export const commitHydratedInstance = $$$config.commitHydratedInstance;
|
||||
export const commitHydratedContainer = $$$config.commitHydratedContainer;
|
||||
export const commitHydratedActivityInstance =
|
||||
$$$config.commitHydratedActivityInstance;
|
||||
export const commitHydratedSuspenseInstance =
|
||||
$$$config.commitHydratedSuspenseInstance;
|
||||
export const finalizeHydratedChildren = $$$config.finalizeHydratedChildren;
|
||||
export const clearActivityBoundary = $$$config.clearActivityBoundary;
|
||||
export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
|
||||
export const clearActivityBoundaryFromContainer =
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ export const enableSuspenseyImages = false;
|
|||
|
||||
export const enableSrcObject = __EXPERIMENTAL__;
|
||||
|
||||
export const enableHydrationChangeEvent = __EXPERIMENTAL__;
|
||||
|
||||
/**
|
||||
* Switches Fiber creation to a simple object instead of a constructor.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export const enableGestureTransition = false;
|
|||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = true;
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
||||
// Flow magic to verify the exports of this file match the original version.
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const enableLazyPublicInstanceInFabric = false;
|
|||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
||||
export const enableFragmentRefs = false;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export const enableLazyPublicInstanceInFabric = false;
|
|||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
||||
export const enableFragmentRefs = false;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const enableLazyPublicInstanceInFabric = false;
|
|||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableFragmentRefs = false;
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const enableLazyPublicInstanceInFabric = false;
|
|||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
|
||||
export const enableFragmentRefs = false;
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export const enableGestureTransition = false;
|
|||
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
|
||||
export const ownerStackLimit = 1e4;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user