From 25d5a7834bfc8c61bd1b5adfa965768506caea10 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 4 May 2025 23:06:17 -0400 Subject: [PATCH 1/5] Add Feature Flag --- packages/shared/ReactFeatureFlags.js | 2 ++ packages/shared/forks/ReactFeatureFlags.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.native-oss.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.js | 1 + .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 1 + packages/shared/forks/ReactFeatureFlags.www.js | 1 + 7 files changed, 8 insertions(+) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 5237e312318e5..9b0e0354e7ecb 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -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. */ diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 93c3ebff00886..bb867acdd87fe 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -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. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index c1e7cebdaecba..b783f37d3dce7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 21a95d51cdc9e..5e0e229e03eee 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 478e1d7953c39..6a93f47c688dd 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 247950f1fb37b..205baff50b343 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 78ff747a065c5..7aed5a6ad3c08 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -114,6 +114,7 @@ export const enableGestureTransition = false; export const enableSuspenseyImages = false; export const enableSrcObject = false; +export const enableHydrationChangeEvent = false; export const ownerStackLimit = 1e4; From 77fb16c75aa52a1bc3050887eadd88913b985e26 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 17:04:16 -0400 Subject: [PATCH 2/5] Add commitHydratedInstance This is like commitMount but for hydration. Allows us to do work in the commit phase. We need to separate Update from Hydrate so I reuse the Callback flag for this. --- .../src/client/ReactFiberConfigDOM.js | 35 +++++++++++++++++++ .../src/ReactFiberCommitHostEffects.js | 23 ++++++++++++ .../src/ReactFiberCommitWork.js | 10 ++++-- .../src/ReactFiberCompleteWork.js | 12 +++++++ .../src/ReactFiberConfigWithNoHydration.js | 2 ++ .../react-reconciler/src/ReactFiberFlags.js | 1 + .../src/forks/ReactFiberConfig.custom.js | 2 ++ 7 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index e1cd27b69bb23..aa9da36e5d658 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -108,6 +108,7 @@ import { enableSuspenseyImages, enableSrcObject, enableViewTransition, + enableHydrationChangeEvent, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -611,6 +612,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 +841,19 @@ export function commitMount( } } +export function commitHydratedInstance( + domElement: Instance, + type: string, + newProps: Props, + internalInstanceHandle: Object, +): void { + // 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. +} + export function commitUpdate( domElement: Instance, type: string, diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index f499045c687ba..023133f2e9781 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -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, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 3fb346d4d2474..93f2a476bd8bc 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -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) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 8a79bcfb71dc7..c6b9334c65d30 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -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( diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js index 9b907b673f892..6c69281d0b834 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 8b26c66859363..e44301d4ed2d2 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -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; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 4e3fb62b09912..f3062d60dd61b 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -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 = From fe815a6621a6c71e94c16dcbeb3590de0b7e5c25 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 19:04:22 -0400 Subject: [PATCH 3/5] Move track call into each special component's init Just prepares to allow for the next commit. --- packages/react-dom-bindings/src/client/ReactDOMComponent.js | 5 ----- packages/react-dom-bindings/src/client/ReactDOMInput.js | 5 ++++- packages/react-dom-bindings/src/client/ReactDOMTextarea.js | 4 ++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 2343445ae0e0d..e6faf54f5d204 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, @@ -1187,7 +1186,6 @@ export function setInitialProperties( name, false, ); - track((domElement: any)); return; } case 'select': { @@ -1285,7 +1283,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': { @@ -3110,7 +3107,6 @@ export function hydrateProperties( props.name, true, ); - track((domElement: any)); break; case 'option': validateOptionProps(domElement, props); @@ -3135,7 +3131,6 @@ export function hydrateProperties( // up necessary since we never stop tracking anymore. validateTextareaProps(domElement, props); initTextarea(domElement, props.value, props.defaultValue, props.children); - track((domElement: any)); break; } diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 33c04e48d0d4a..d3d455ff67bb0 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -12,7 +12,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree'; import {getToStringValue, toString} from './ToStringValue'; -import {updateValueIfChanged} from './inputValueTracking'; +import {track, updateValueIfChanged} from './inputValueTracking'; import getActiveElement from './getActiveElement'; import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; @@ -229,6 +229,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; } @@ -335,6 +337,7 @@ export function initInput( } node.name = name; } + track((element: any)); } export function restoreControlledInputState(element: Element, props: Object) { diff --git a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js index b0a1f520fdb2a..e45d18d0fbc26 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js +++ b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js @@ -13,6 +13,8 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur import {getToStringValue, toString} from './ToStringValue'; import {disableTextareaChildren} from 'shared/ReactFeatureFlags'; +import {track} from './inputValueTracking'; + let didWarnValDefaultVal = false; /** @@ -140,6 +142,8 @@ export function initTextarea( node.value = textContent; } } + + track((element: any)); } export function restoreControlledTextareaState( From 43ad28069a9fec05127bfa703748417720183210 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 18:59:49 -0400 Subject: [PATCH 4/5] 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. --- .../src/client/ReactDOMComponent.js | 32 +++++++---- .../src/client/ReactDOMInput.js | 44 +++++++++++++-- .../src/client/ReactDOMSelect.js | 55 ++++++++++++++++++- .../src/client/ReactDOMTextarea.js | 27 ++++++++- .../src/client/ReactFiberConfigDOM.js | 39 ++++++++++++- .../src/client/inputValueTracking.js | 46 +++++++++++++--- 6 files changed, 217 insertions(+), 26 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index e6faf54f5d204..8ae6021aec81e 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -66,6 +66,7 @@ import sanitizeURL from '../shared/sanitizeURL'; import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking'; import { + enableHydrationChangeEvent, enableScrollEndPolyfill, enableSrcObject, enableTrustedTypesIntegration, @@ -3097,16 +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, - ); + if (!enableHydrationChangeEvent) { + initInput( + domElement, + props.value, + props.defaultValue, + props.checked, + props.defaultChecked, + props.type, + props.name, + true, + ); + } break; case 'option': validateOptionProps(domElement, props); @@ -3130,7 +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); + 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 d3d455ff67bb0..e317021921e23 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -12,9 +12,12 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree'; import {getToStringValue, toString} from './ToStringValue'; -import {track, 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'; @@ -241,7 +244,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. @@ -299,7 +302,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 { @@ -340,6 +343,39 @@ export function initInput( 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. + // TODO: Queue replay. + } +} + export function restoreControlledInputState(element: Element, props: Object) { const rootNode: HTMLInputElement = (element: any); updateInput( diff --git a/packages/react-dom-bindings/src/client/ReactDOMSelect.js b/packages/react-dom-bindings/src/client/ReactDOMSelect.js index 984abbc07c769..c4427ee010c9e 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMSelect.js +++ b/packages/react-dom-bindings/src/client/ReactDOMSelect.js @@ -86,7 +86,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 +157,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. + // TODO: Queue replay. + } +} + 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 e45d18d0fbc26..a46f32abaf3b3 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js +++ b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js @@ -13,7 +13,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur import {getToStringValue, toString} from './ToStringValue'; import {disableTextareaChildren} from 'shared/ReactFeatureFlags'; -import {track} from './inputValueTracking'; +import {track, trackHydrated} from './inputValueTracking'; let didWarnValDefaultVal = false; @@ -146,6 +146,31 @@ export function initTextarea( 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. + // TODO: Queue replay. + } +} + export function restoreControlledTextareaState( element: Element, props: Object, diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index aa9da36e5d658..1f7a4f915c087 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 { @@ -155,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, @@ -844,14 +851,44 @@ export function commitMount( export function commitHydratedInstance( domElement: Instance, type: string, - newProps: Props, + 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( 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 { From 0812b1fc3e905a8380d551484ad4a857ceff2fb3 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 5 May 2025 20:49:49 -0400 Subject: [PATCH 5/5] Replay onChange events if there was a change to the value before hydration We do this by simulating a real DOM event so that custom listeners get to observe it as well. --- .../src/client/ReactDOMInput.js | 8 ++- .../src/client/ReactDOMSelect.js | 5 +- .../src/client/ReactDOMTextarea.js | 5 +- .../src/events/ReactDOMEventReplaying.js | 55 +++++++++++++++++-- .../src/__tests__/ReactDOMInput-test.js | 45 +++++++++++---- ...OMServerIntegrationUserInteraction-test.js | 33 +++++------ 6 files changed, 112 insertions(+), 39 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index e317021921e23..b6e665e128836 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -22,6 +22,7 @@ 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; @@ -371,8 +372,11 @@ export function hydrateInput( const changed = trackHydrated((node: any), initialValue, initialChecked); if (changed) { // If the current value is different, that suggests that the user - // changed it before hydration. - // TODO: Queue replay. + // 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); + } } } diff --git a/packages/react-dom-bindings/src/client/ReactDOMSelect.js b/packages/react-dom-bindings/src/client/ReactDOMSelect.js index c4427ee010c9e..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; @@ -205,8 +206,8 @@ export function hydrateSelect( } if (changed) { // If the current selection is different than our initial that suggests that the user - // changed it before hydration. - // TODO: Queue replay. + // changed it before hydration. Queue a replay of the change event. + queueChangeEvent(node); } } diff --git a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js index a46f32abaf3b3..bc346b4bce40d 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMTextarea.js +++ b/packages/react-dom-bindings/src/client/ReactDOMTextarea.js @@ -14,6 +14,7 @@ import {getToStringValue, toString} from './ToStringValue'; import {disableTextareaChildren} from 'shared/ReactFeatureFlags'; import {track, trackHydrated} from './inputValueTracking'; +import {queueChangeEvent} from '../events/ReactDOMEventReplaying'; let didWarnValDefaultVal = false; @@ -166,8 +167,8 @@ export function hydrateTextarea( const changed = trackHydrated((node: any), stringValue, false); if (changed) { // If the current value is different, that suggests that the user - // changed it before hydration. - // TODO: Queue replay. + // changed it before hydration. Queue a replay of the change event. + queueChangeEvent(node); } } diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index a6f6f3055ae70..d600917534cf6 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,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( 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..be0d4533af7e5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js @@ -278,10 +278,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => { await testUserInteractionBeforeClientRender( 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(