diff --git a/fixtures/ssr/src/components/Page.js b/fixtures/ssr/src/components/Page.js index d7b4d5c813e98..1a4c6f79a3e78 100644 --- a/fixtures/ssr/src/components/Page.js +++ b/fixtures/ssr/src/components/Page.js @@ -11,10 +11,17 @@ const autofocusedInputs = [ ]; export default class Page extends Component { - state = {active: false}; + state = {active: false, value: ''}; handleClick = e => { this.setState({active: true}); }; + handleChange = e => { + this.setState({value: e.target.value}); + }; + componentDidMount() { + // Rerender on mount + this.setState({mounted: true}); + } render() { const link = ( @@ -30,6 +37,10 @@ export default class Page extends Component {

Autofocus on page load: {autofocusedInputs}

{!this.state.active ? link : 'Thanks!'}

{this.state.active &&

Autofocus on update: {autofocusedInputs}

} +

+ Controlled input:{' '} + +

); diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 2343445ae0e0d..8ae6021aec81e 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -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; } diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 33c04e48d0d4a..b6e665e128836 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -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) { diff --git a/packages/react-dom-bindings/src/client/ReactDOMSelect.js b/packages/react-dom-bindings/src/client/ReactDOMSelect.js index 984abbc07c769..00136aa8175b1 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMSelect.js +++ b/packages/react-dom-bindings/src/client/ReactDOMSelect.js @@ -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); + 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, diff --git a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js index b0a1f520fdb2a..bc346b4bce40d 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js +++ b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js @@ -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( diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index e1cd27b69bb23..7d705e059bdf8 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -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 { @@ -96,7 +99,10 @@ import { DOCUMENT_FRAGMENT_NODE, } from './HTMLNodeType'; -import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; +import { + flushEventReplaying, + retryIfBlockedOn, +} from '../events/ReactDOMEventReplaying'; import { enableCreateEventHandleAPI, @@ -108,6 +114,7 @@ import { enableSuspenseyImages, enableSrcObject, enableViewTransition, + enableHydrationChangeEvent, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -154,6 +161,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 +622,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 +851,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, @@ -3583,6 +3658,12 @@ export function commitHydratedSuspenseInstance( retryIfBlockedOn(suspenseInstance); } +export function flushHydrationEvents(): void { + if (enableHydrationChangeEvent) { + flushEventReplaying(); + } +} + export function shouldDeleteUnhydratedTailInstances( parentType: string, ): boolean { diff --git a/packages/react-dom-bindings/src/client/inputValueTracking.js b/packages/react-dom-bindings/src/client/inputValueTracking.js index 5e1ca58687817..f89617fe6dae6 100644 --- a/packages/react-dom-bindings/src/client/inputValueTracking.js +++ b/packages/react-dom-bindings/src/client/inputValueTracking.js @@ -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 { diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index a6f6f3055ae70..2763b2fc129a1 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -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 = new Map(); const queuedPointerCaptures: Map = new Map(); // We could consider replaying selectionchange and touchmoves too. +const queuedChangeEventTargets: Array = []; + 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,29 @@ 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 flushEventReplaying(): void { + // Synchronously flush any event replaying so that it gets observed before + // any new updates are applied. + if (hasScheduledReplayAttempt) { + replayUnblockedEvents(); + } +} + +export function queueChangeEvent(target: EventTarget): void { + if (enableHydrationChangeEvent) { + queuedChangeEventTargets.push(target); + if (!hasScheduledReplayAttempt) { + hasScheduledReplayAttempt = true; + } + } } function scheduleCallbackIfUnblocked( @@ -445,10 +497,12 @@ function scheduleCallbackIfUnblocked( queuedEvent.blockedOn = null; if (!hasScheduledReplayAttempt) { hasScheduledReplayAttempt = true; - // Schedule a callback to attempt replaying as many events as are - // now unblocked. This first might not actually be unblocked yet. - // We could check it early to avoid scheduling an unnecessary callback. - scheduleCallback(NormalPriority, replayUnblockedEvents); + if (!enableHydrationChangeEvent) { + // Schedule a callback to attempt replaying as many events as are + // now unblocked. This first might not actually be unblocked yet. + // We could check it early to avoid scheduling an unnecessary callback. + scheduleCallback(NormalPriority, replayUnblockedEvents); + } } } } diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 5b47095d7c755..04bd96fe2e83e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -1536,10 +1536,14 @@ describe('ReactDOMInput', () => { ReactDOMClient.hydrateRoot(container, ); }); - // 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, ); }); - // 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); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js index 1cae7f15b0b4b..70109949baf10 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js @@ -52,6 +52,12 @@ describe('ReactDOMServerIntegrationUserInteraction', () => { } this.setState({value: event.target.value}); } + componentDidMount() { + if (this.props.cascade) { + // Trigger a cascading render immediately upon hydration which rerenders the input. + this.setState({cascade: true}); + } + } render() { return ( { } this.setState({value: event.target.value}); } + componentDidMount() { + if (this.props.cascade) { + // Trigger a cascading render immediately upon hydration which rerenders the textarea. + this.setState({cascade: true}); + } + } render() { return (