From 1a31a814f16838e2c9edfce0d244264c43184f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 6 Nov 2025 16:02:06 -0500 Subject: [PATCH 1/3] Escape View Transition Name Strings as base64 (#35060) This is an alternative to #35059. If the name needs escaping, then instead of escaping it, we just use a base64 name. This wouldn't allow you to match on an escaped name in your own CSS like you should be able to if browsers worked properly. But at least it would provide matching name in current browsers which is probably sufficient if you're using auto-generated names. This also covers some cases where `CSS.escape()` isn't sufficient anyway like when the name ends in a dot. --- .../react-dom-bindings/src/client/ReactFiberConfigDOM.js | 7 ++++++- .../ReactDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 7 ++++++- scripts/flow/environment.js | 4 ++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index bbecbb66356..e4c45ccc4c3 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -1435,8 +1435,13 @@ export function applyViewTransitionName( className: ?string, ): void { instance = ((instance: any): HTMLElement); + // If the name isn't valid CSS identifier, base64 encode the name instead. + // This doesn't let you select it in custom CSS selectors but it does work in current + // browsers. + const escapedName = + CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name; // $FlowFixMe[prop-missing] - instance.style.viewTransitionName = name; + instance.style.viewTransitionName = escapedName; if (className != null) { // $FlowFixMe[prop-missing] instance.style.viewTransitionClass = className; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 4e7fb4b73f6..cac2283236f 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -8,7 +8,7 @@ export const clientRenderBoundary = export const completeBoundary = '$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;ba&&2E3q&&2E3q&&2E3; } } + +declare class CSS { + static escape(str: string): string; +} From 37b089a59cc14b6442b27303942d62425f9cea35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 6 Nov 2025 16:03:02 -0500 Subject: [PATCH 2/3] Don't skip content in visible offscreen trees for View Transitions (#35063) Also, don't not skip hidden trees. Memoized state is null when an Offscreen boundary (Suspense or Activity) is visible. This logic was inversed in a couple of View Transition checks which caused pairs to be discovered or not discovered incorrectly for insertion and deletion of Suspense or Activity boundaries. --- .../react-reconciler/src/ReactFiberCommitViewTransitions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index c8e59afb88e..20abaa673c2 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -233,7 +233,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { } let child = placement.child; while (child !== null) { - if (child.tag === OffscreenComponent && child.memoizedState === null) { + if (child.tag === OffscreenComponent && child.memoizedState !== null) { // This tree was already hidden so we skip it. } else { commitAppearingPairViewTransitions(child); @@ -347,7 +347,7 @@ function commitDeletedPairViewTransitions(deletion: Fiber): void { } let child = deletion.child; while (child !== null) { - if (child.tag === OffscreenComponent && child.memoizedState === null) { + if (child.tag === OffscreenComponent && child.memoizedState !== null) { // This tree was already hidden so we skip it. } else { if ( @@ -550,7 +550,7 @@ function restorePairedViewTransitions(parent: Fiber): void { } let child = parent.child; while (child !== null) { - if (child.tag === OffscreenComponent && child.memoizedState === null) { + if (child.tag === OffscreenComponent && child.memoizedState !== null) { // This tree was already hidden so we skip it. } else { if ( From a44e750e87fd0869cdeda0418e279e19c1ee07dd Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Thu, 6 Nov 2025 18:17:53 -0500 Subject: [PATCH 3/3] Store instance handles in an internal map behind flag (#35053) We already append `randomKey` to each handle name to prevent external libraries from accessing and relying on these internals. But more libraries recently have been getting around this by simply iterating over the element properties and using a `startsWith` check. This flag allows us to experiment with moving these handles to an internal map. This PR starts with the two most common internals, the props object and the fiber. We can consider moving additional properties such as the container root and others depending on perf results. --- .../src/client/ReactDOMComponentTree.js | 90 ++++++++++++++++--- packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 2 + .../forks/ReactFeatureFlags.test-renderer.js | 2 + .../ReactFeatureFlags.test-renderer.www.js | 2 + .../forks/ReactFeatureFlags.www-dynamic.js | 2 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index 7fef1105111..8a07dfefb0f 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -38,6 +38,8 @@ import {getParentHydrationBoundary} from './ReactFiberConfigDOM'; import {enableScopeAPI} from 'shared/ReactFeatureFlags'; +import {enableInternalInstanceMap} from 'shared/ReactFeatureFlags'; + const randomKey = Math.random().toString(36).slice(2); const internalInstanceKey = '__reactFiber$' + randomKey; const internalPropsKey = '__reactProps$' + randomKey; @@ -49,7 +51,32 @@ const internalRootNodeResourcesKey = '__reactResources$' + randomKey; const internalHoistableMarker = '__reactMarker$' + randomKey; const internalScrollTimer = '__reactScroll$' + randomKey; +type InstanceUnion = + | Instance + | TextInstance + | SuspenseInstance + | ActivityInstance + | ReactScopeInstance + | Container; + +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +const internalInstanceMap: + | WeakMap + | Map = new PossiblyWeakMap(); +const internalPropsMap: + | WeakMap + | Map = new PossiblyWeakMap(); + export function detachDeletedInstance(node: Instance): void { + if (enableInternalInstanceMap) { + internalInstanceMap.delete(node); + internalPropsMap.delete(node); + delete (node: any)[internalEventHandlersKey]; + delete (node: any)[internalEventHandlerListenersKey]; + delete (node: any)[internalEventHandlesSetKey]; + delete (node: any)[internalRootNodeResourcesKey]; + return; + } // TODO: This function is only called on host components. I don't think all of // these fields are relevant. delete (node: any)[internalInstanceKey]; @@ -68,6 +95,10 @@ export function precacheFiberNode( | ActivityInstance | ReactScopeInstance, ): void { + if (enableInternalInstanceMap) { + internalInstanceMap.set(node, hostInst); + return; + } (node: any)[internalInstanceKey] = hostInst; } @@ -95,7 +126,12 @@ export function isContainerMarkedAsRoot(node: Container): boolean { // HostRoot back. To get to the HostRoot, you need to pass a child of it. // The same thing applies to Suspense and Activity boundaries. export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { - let targetInst = (targetNode: any)[internalInstanceKey]; + let targetInst: void | Fiber; + if (enableInternalInstanceMap) { + targetInst = internalInstanceMap.get(((targetNode: any): InstanceUnion)); + } else { + targetInst = (targetNode: any)[internalInstanceKey]; + } if (targetInst) { // Don't return HostRoot, SuspenseComponent or ActivityComponent here. return targetInst; @@ -112,9 +148,15 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { // itself because the fibers are conceptually between the container // node and the first child. It isn't surrounding the container node. // If it's not a container, we check if it's an instance. - targetInst = - (parentNode: any)[internalContainerInstanceKey] || - (parentNode: any)[internalInstanceKey]; + if (enableInternalInstanceMap) { + targetInst = + (parentNode: any)[internalContainerInstanceKey] || + internalInstanceMap.get(((parentNode: any): InstanceUnion)); + } else { + targetInst = + (parentNode: any)[internalContainerInstanceKey] || + (parentNode: any)[internalInstanceKey]; + } if (targetInst) { // Since this wasn't the direct target of the event, we might have // stepped past dehydrated DOM nodes to get here. However they could @@ -147,8 +189,10 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { // have had an internalInstanceKey on it. // Let's get the fiber associated with the SuspenseComponent // as the deepest instance. - // $FlowFixMe[prop-missing] - const targetFiber = hydrationInstance[internalInstanceKey]; + const targetFiber = enableInternalInstanceMap + ? internalInstanceMap.get(hydrationInstance) + : // $FlowFixMe[prop-missing] + hydrationInstance[internalInstanceKey]; if (targetFiber) { return targetFiber; } @@ -175,9 +219,16 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { * instance, or null if the node was not rendered by this React. */ export function getInstanceFromNode(node: Node): Fiber | null { - const inst = - (node: any)[internalInstanceKey] || - (node: any)[internalContainerInstanceKey]; + let inst: void | null | Fiber; + if (enableInternalInstanceMap) { + inst = + internalInstanceMap.get(((node: any): InstanceUnion)) || + (node: any)[internalContainerInstanceKey]; + } else { + inst = + (node: any)[internalInstanceKey] || + (node: any)[internalContainerInstanceKey]; + } if (inst) { const tag = inst.tag; if ( @@ -226,16 +277,24 @@ export function getFiberCurrentPropsFromNode( | TextInstance | SuspenseInstance | ActivityInstance, -): Props { +): Props | null { + if (enableInternalInstanceMap) { + return internalPropsMap.get(node) || null; + } return (node: any)[internalPropsKey] || null; } export function updateFiberProps(node: Instance, props: Props): void { + if (enableInternalInstanceMap) { + internalPropsMap.set(node, props); + return; + } (node: any)[internalPropsKey] = props; } export function getEventListenerSet(node: EventTarget): Set { - let elementListenerSet = (node: any)[internalEventHandlersKey]; + let elementListenerSet: Set | void; + elementListenerSet = (node: any)[internalEventHandlersKey]; if (elementListenerSet === undefined) { elementListenerSet = (node: any)[internalEventHandlersKey] = new Set(); } @@ -246,6 +305,9 @@ export function getFiberFromScopeInstance( scope: ReactScopeInstance, ): null | Fiber { if (enableScopeAPI) { + if (enableInternalInstanceMap) { + return internalInstanceMap.get(((scope: any): InstanceUnion)) || null; + } return (scope: any)[internalInstanceKey] || null; } return null; @@ -318,6 +380,12 @@ export function clearScrollEndTimer(node: EventTarget): void { } export function isOwnedInstance(node: Node): boolean { + if (enableInternalInstanceMap) { + return !!( + (node: any)[internalHoistableMarker] || + internalInstanceMap.has((node: any)) + ); + } return !!( (node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey] ); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index c31809276bd..a5befa11a0d 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -147,6 +147,8 @@ export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableInternalInstanceMap: boolean = false; + // ----------------------------------------------------------------------------- // Ready for next major. // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index e1b26cdb83b..71c64b9db03 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -84,6 +84,7 @@ export const enableComponentPerformanceTrack: boolean = __PROFILE__ && dynamicFlags.enableComponentPerformanceTrack; export const enablePerformanceIssueReporting: boolean = enableComponentPerformanceTrack; +export const enableInternalInstanceMap: boolean = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3880556d247..cf765d7d689 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -76,6 +76,8 @@ export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = false; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableInternalInstanceMap: boolean = false; + // Profiling Only export const enableProfilerTimer: boolean = __PROFILE__; export const enableProfilerCommitHooks: boolean = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 88c46d26e2e..0747f0e8be4 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -77,6 +77,8 @@ export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableInternalInstanceMap: boolean = false; + // TODO: This must be in sync with the main ReactFeatureFlags file because // the Test Renderer's value must be the same as the one used by the // react package. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index d815fd7ddc8..2b40c1b01c6 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -85,5 +85,7 @@ export const enableFragmentRefsScrollIntoView: boolean = false; export const enableFragmentRefsInstanceHandles: boolean = false; export const ownerStackLimit = 1e4; +export const enableInternalInstanceMap: boolean = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 674aa5d3201..1d174609cb9 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -38,6 +38,8 @@ export const enableFragmentRefs: boolean = __VARIANT__; export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__; export const enableAsyncDebugInfo: boolean = __VARIANT__; +export const enableInternalInstanceMap: boolean = __VARIANT__; + // TODO: These flags are hard-coded to the default values used in open source. // Update the tests so that they pass in either mode, then set these // to __VARIANT__. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 4ace06ed4c4..029cd0d196e 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -35,6 +35,7 @@ export const { enableFragmentRefs, enableFragmentRefsScrollIntoView, enableAsyncDebugInfo, + enableInternalInstanceMap, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build.