diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js
index 08224a57c4d..ae11e1d198e 100644
--- a/fixtures/view-transition/server/render.js
+++ b/fixtures/view-transition/server/render.js
@@ -2,6 +2,9 @@ import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import App from '../src/components/App.js';
+import {resetFeedReveal} from '../src/components/NestedParentExit.js';
+import {resetPageReveal} from '../src/components/Page.js';
+import {resetNestedReveal} from '../src/components/NestedReveal.js';
let assets;
if (process.env.NODE_ENV === 'development') {
@@ -15,6 +18,10 @@ if (process.env.NODE_ENV === 'development') {
}
export default function render(url, res) {
+ // Force resuspend on every load
+ resetFeedReveal();
+ resetPageReveal();
+ resetNestedReveal();
res.socket.on('error', error => {
// Log fatal errors
console.error('Fatal', error);
diff --git a/fixtures/view-transition/src/components/NestedParentExit.css b/fixtures/view-transition/src/components/NestedParentExit.css
index 7448b1c24ab..dc7001035cd 100644
--- a/fixtures/view-transition/src/components/NestedParentExit.css
+++ b/fixtures/view-transition/src/components/NestedParentExit.css
@@ -210,3 +210,52 @@
::view-transition-old(.nested-back-btn-exit):only-child {
animation: nested-back-btn-exit 200ms ease-in forwards;
}
+
+/* Suspense reveal of the feed (drives the SSR parent enter/exit demo). */
+.feed-item-skeleton {
+ cursor: default;
+}
+
+.skeleton-line {
+ background: #ddd;
+ border-radius: 4px;
+}
+
+.skeleton-title {
+ height: 15px;
+ width: 60%;
+ margin: 0 0 0.35rem;
+}
+
+.skeleton-body {
+ height: 13px;
+ width: 90%;
+}
+
+@keyframes nested-feed-enter {
+ from {
+ opacity: 0;
+ translate: 0 20px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+::view-transition-new(.nested-feed-enter) {
+ animation: nested-feed-enter 450ms ease-out both;
+}
+
+@keyframes nested-feed-exit {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+::view-transition-old(.nested-feed-exit) {
+ animation: nested-feed-exit 300ms ease-in forwards;
+}
diff --git a/fixtures/view-transition/src/components/NestedParentExit.js b/fixtures/view-transition/src/components/NestedParentExit.js
index b77ca0bd6bd..6b26966dc5d 100644
--- a/fixtures/view-transition/src/components/NestedParentExit.js
+++ b/fixtures/view-transition/src/components/NestedParentExit.js
@@ -1,5 +1,7 @@
import React, {
ViewTransition,
+ Suspense,
+ use,
useState,
useOptimistic,
startTransition,
@@ -14,6 +16,45 @@ const items = [
{id: 3, title: 'Third Post', body: 'Hello from the third post.'},
];
+let feedRevealPromise = null;
+
+export function resetFeedReveal() {
+ feedRevealPromise = null;
+}
+
+function FeedReveal() {
+ if (feedRevealPromise === null) {
+ feedRevealPromise = new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ use(feedRevealPromise);
+ return null;
+}
+
+function FeedSkeleton() {
+ return (
+
+
+ {items.map(item => (
+ // Mirrors FeedItem: each skeleton row relays the exit (level 1), and
+ // its title/body placeholders relay again (level 2) so they animate
+ // out separately from the row — up and to the right. This exercises
+ // the nested parentExit relay through the SSR streaming reveal.
+
+
+
+ ))}
+
+
+ );
+}
+
function logGestureParent(kind, title, _timeline, _options, _instance, types) {
// eslint-disable-next-line no-console
console.log(`[NestedParentExit] onGestureParent${kind}`, title, types);
@@ -159,17 +200,26 @@ export default function NestedParentExit() {
{selected ? (
) : (
- <>
- {items.map((item, index) => (
-
- ))}
- >
+ }>
+ {/* The entering ViewTransition is the direct child of the
+ Suspense content, so it activates an enter scope and the
+ feed items below relay parentEnter (emitting
+ vt-parent-enter annotations in Fizz). */}
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+
+
)}
diff --git a/fixtures/view-transition/src/components/NestedReveal.js b/fixtures/view-transition/src/components/NestedReveal.js
index 497f4430f6c..9ba3cdcab00 100644
--- a/fixtures/view-transition/src/components/NestedReveal.js
+++ b/fixtures/view-transition/src/components/NestedReveal.js
@@ -12,6 +12,11 @@ function Use({useable}) {
let delay1;
let delay2;
+export function resetNestedReveal() {
+ delay1 = undefined;
+ delay2 = undefined;
+}
+
export default function NestedReveal({}) {
if (!delay1) {
delay1 = sleep(100);
diff --git a/fixtures/view-transition/src/components/Page.css b/fixtures/view-transition/src/components/Page.css
index 63ae718e740..16e250a99da 100644
--- a/fixtures/view-transition/src/components/Page.css
+++ b/fixtures/view-transition/src/components/Page.css
@@ -18,4 +18,45 @@
top: 10px;
left: 360px;
border: 1px solid #ccc;
-}
\ No newline at end of file
+}
+
+.examples {
+ display: grid;
+ /* Wide enough that the demos (the ~340px swipe-recognizer boxes) fit without
+ overflowing once card + content padding is subtracted. */
+ grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
+ gap: 20px;
+ align-items: start;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px;
+}
+
+.example-card {
+ display: flex;
+ flex-direction: column;
+ min-height: 320px;
+ background: #eee;
+ border: 1px solid #ddd;
+ border-radius: 10px;
+ padding: 20px;
+ box-sizing: border-box;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+.example-card-title {
+ margin: 0 0 16px;
+ font-size: 15px;
+ font-weight: 600;
+ color: #333;
+}
+
+.example-card-content {
+ flex: 1;
+ width: 100%;
+ background: #fff;
+ border-radius: 10px;
+ padding: 16px;
+ box-sizing: border-box;
+ overflow: auto;
+}
diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 37f5624e947..af23b3830df 100644
--- a/fixtures/view-transition/src/components/Page.js
+++ b/fixtures/view-transition/src/components/Page.js
@@ -42,6 +42,15 @@ const b = (
);
+function ExampleCard({title, children}) {
+ return (
+
+ {title ?
{title} : null}
+
{children}
+
+ );
+}
+
function Component() {
// Test inserting fonts with style tags using useInsertionEffect. This is not recommended but
// used to test that gestures etc works with useInsertionEffect so that stylesheet based
@@ -86,6 +95,9 @@ function Id() {
}
let wait;
+export function resetPageReveal() {
+ wait = undefined;
+}
function Suspend() {
if (!wait) wait = sleep(500);
return React.use(wait);
@@ -214,116 +226,122 @@ export default function Page({url, navigate}) {
);
return (
-
-
{
- addTransitionType(
- direction === 'left' ? 'navigation-forward' : 'navigation-back'
- );
- optimisticNavigate(direction);
- }}
- direction={show ? 'left' : 'right'}>
- {
- navigate(url === '/?b' ? '/?a' : '/?b');
- }}>
- {url === '/?b' ? 'Goto A' : 'Goto B'}
-
-
-
-
-
-
- {!show ? 'A' : 'B' + counter}
-
-
-
-
- {!show ? 'A' + counter : 'B'}
-
- {
- // Using url instead of renderedUrl here lets us only update this on commit.
- url === '/?b' ? (
-
- {a}
- {b}
-
- ) : (
+
+
+ {
+ addTransitionType(
+ direction === 'left' ? 'navigation-forward' : 'navigation-back'
+ );
+ optimisticNavigate(direction);
+ }}
+ direction={show ? 'left' : 'right'}>
+ {
+ navigate(url === '/?b' ? '/?a' : '/?b');
+ }}>
+ {url === '/?b' ? 'Goto A' : 'Goto B'}
+
+
+
+
- {b}
- {a}
+
+ {!show ? 'A' : 'B' + counter}
+
- )
- }
-
- {show ? (
- hello{exclamation}
- ) : (
-
- )}
-
-
-
-
- {show ? null : (
-
- world{exclamation}
- )}
-
+
+ {!show ? 'A' + counter : 'B'}
+
+ {
+ // Using url instead of renderedUrl here lets us only update this on commit.
+ url === '/?b' ? (
+
+ {a}
+ {b}
+
+ ) : (
+
+ {b}
+ {a}
+
+ )
+ }
- !!
+ {show ? (
+ hello{exclamation}
+ ) : (
+
+ )}
-
-
+
+
+ {show ? null : (
+
+ world{exclamation}
+
+ )}
+
+
+ !!
+
+
+
+
+
+ █████
+
+
████
+
███████
+
████
+
██
+
██████
+
███
+
████
+
+
+ }>
+
these
+
rows
- █████
+ exist
-
████
-
███████
-
████
-
██
-
██████
-
███
-
████
+
to
+
test
+
scrolling
+
content
+
out
+
of
+ {portal}
+
the
+
viewport
+
- }>
-
-
-
these
-
rows
-
- exist
-
-
to
-
test
-
scrolling
-
content
-
out
-
of
- {portal}
-
the
-
viewport
-
-
-
- {show ?
: null}
-
-
-
-
-
-
+ {show ? : null}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js
index ccfa2e6bb85..1e8db9c833b 100644
--- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js
+++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js
@@ -73,6 +73,7 @@ import {
enableSrcObject,
enableTrustedTypesIntegration,
enableViewTransition,
+ enableViewTransitionParentEnterExit,
} from 'shared/ReactFeatureFlags';
import {
mediaEventTypes,
@@ -240,7 +241,10 @@ function hasViewTransition(htmlElement: HTMLElement): boolean {
htmlElement.getAttribute('vt-share') ||
htmlElement.getAttribute('vt-exit') ||
htmlElement.getAttribute('vt-enter') ||
- htmlElement.getAttribute('vt-update')
+ htmlElement.getAttribute('vt-update') ||
+ (enableViewTransitionParentEnterExit &&
+ (htmlElement.getAttribute('vt-parent-enter') ||
+ htmlElement.getAttribute('vt-parent-exit')))
);
}
@@ -3307,6 +3311,8 @@ export function diffHydratedProperties(
case 'vt-enter':
case 'vt-exit':
case 'vt-share':
+ case 'vt-parent-enter':
+ case 'vt-parent-exit':
if (enableViewTransition) {
// View Transition annotations are expected from the Server Runtime.
// However, if they're also specified on the client and don't match
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index d4e4dfde408..90922adc3b6 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -62,13 +62,13 @@ import {
isOwnedInstance,
} from './ReactDOMComponentTree';
import {
- traverseFragmentInstance,
- getFragmentParentHostFiber,
+ traverseFragmentInstancesAndTextInstances,
+ getFragmentParentInstanceOrContainerFiber,
getInstanceFromHostFiber,
isFiberFollowing,
isFiberPreceding,
- getFragmentInstanceSiblings,
- traverseFragmentInstanceDeeply,
+ getFragmentInstanceOrTextInstanceSiblings,
+ traverseFragmentInstancesAndTextInstancesDeeply,
fiberIsPortaledIntoHost,
isFiberContainedByFragment,
isFragmentContainedByFiber,
@@ -223,6 +223,9 @@ export type TextInstance = Text;
type InstanceWithFragmentHandles = Instance & {
reactFragments?: Set,
};
+type HostNodeWithFragmentHandles = (Instance | TextInstance) & {
+ reactFragments?: Set,
+};
declare class ActivityInterface extends Comment {}
declare class SuspenseInterface extends Comment {
@@ -3036,7 +3039,7 @@ FragmentInstance.prototype.addEventListener = function (
indexOfEventListener(listeners, type, listener, optionsOrUseCapture) === -1;
if (isNewEventListener) {
listeners.push({type, listener, optionsOrUseCapture});
- traverseFragmentInstance(
+ traverseFragmentInstancesAndTextInstances(
this._fragmentFiber,
addEventListenerToChild,
type,
@@ -3052,7 +3055,7 @@ function addEventListenerToChild(
listener: EventListener,
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
): boolean {
- const instance = getInstanceFromHostFiber(child);
+ const instance = getInstanceFromHostFiber(child);
instance.addEventListener(type, listener, optionsOrUseCapture);
return false;
}
@@ -3068,7 +3071,7 @@ FragmentInstance.prototype.removeEventListener = function (
return;
}
if (typeof listeners !== 'undefined' && listeners.length > 0) {
- traverseFragmentInstance(
+ traverseFragmentInstancesAndTextInstances(
this._fragmentFiber,
removeEventListenerFromChild,
type,
@@ -3092,7 +3095,7 @@ function removeEventListenerFromChild(
listener: EventListener,
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
): boolean {
- const instance = getInstanceFromHostFiber(child);
+ const instance = getInstanceFromHostFiber(child);
instance.removeEventListener(type, listener, optionsOrUseCapture);
return false;
}
@@ -3136,12 +3139,15 @@ FragmentInstance.prototype.dispatchEvent = function (
this: FragmentInstanceType,
event: Event,
): boolean {
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return true;
}
- const parentHostInstance =
- getInstanceFromHostFiber(parentHostFiber);
+ const parentHostInstance = getInstanceFromHostFiber(
+ parentHostFiber,
+ );
const eventListeners = this._eventListeners;
if (
(eventListeners !== null && eventListeners.length > 0) ||
@@ -3173,7 +3179,7 @@ FragmentInstance.prototype.focus = function (
this: FragmentInstanceType,
focusOptions?: FocusOptions,
): void {
- traverseFragmentInstanceDeeply(
+ traverseFragmentInstancesAndTextInstancesDeeply(
this._fragmentFiber,
setFocusOnFiberIfFocusable,
focusOptions,
@@ -3198,7 +3204,7 @@ FragmentInstance.prototype.focusLast = function (
focusOptions?: FocusOptions,
): void {
const children: Array = [];
- traverseFragmentInstanceDeeply(
+ traverseFragmentInstancesAndTextInstancesDeeply(
this._fragmentFiber,
collectChildren,
children,
@@ -3217,18 +3223,25 @@ function collectChildren(child: Fiber, collection: Array): boolean {
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void {
// Early exit if activeElement is not within the fragment's parent
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return;
}
- const parentHostInstance =
- getInstanceFromHostFiber(parentHostFiber);
- const activeElement = parentHostInstance.ownerDocument.activeElement;
- if (activeElement === null || !parentHostInstance.contains(activeElement)) {
+ const parentInstanceOrContainer = getInstanceFromHostFiber<
+ Instance | Container,
+ >(parentHostFiber);
+ // TODO: Handle parentInstanceOrContainer being a document
+ const activeElement = parentInstanceOrContainer.ownerDocument.activeElement;
+ if (
+ activeElement === null ||
+ !parentInstanceOrContainer.contains(activeElement)
+ ) {
return;
}
- traverseFragmentInstance(
+ traverseFragmentInstancesAndTextInstances(
this._fragmentFiber,
blurActiveElementWithinFragment,
activeElement,
@@ -3259,16 +3272,19 @@ FragmentInstance.prototype.observeUsing = function (
if (enableFragmentRefsTextNodes) {
let hasText = false;
let hasElement = false;
- traverseFragmentInstance(this._fragmentFiber, (child: Fiber) => {
- if (child.tag === HostText) {
- hasText = true;
- } else {
- // Stop traversal, found element
- hasElement = true;
- return true;
- }
- return false;
- });
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ (child: Fiber) => {
+ if (child.tag === HostText) {
+ hasText = true;
+ } else {
+ // Stop traversal, found element
+ hasElement = true;
+ return true;
+ }
+ return false;
+ },
+ );
if (hasText && !hasElement) {
console.error(
'observeUsing() was called on a FragmentInstance with only text children. ' +
@@ -3281,7 +3297,11 @@ FragmentInstance.prototype.observeUsing = function (
this._observers = new Set();
}
this._observers.add(observer);
- traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ observeChild,
+ observer,
+ );
};
function observeChild(
child: Fiber,
@@ -3312,7 +3332,11 @@ FragmentInstance.prototype.unobserveUsing = function (
}
} else {
observers.delete(observer);
- traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ unobserveChild,
+ observer,
+ );
}
};
function unobserveChild(
@@ -3334,7 +3358,11 @@ FragmentInstance.prototype.getClientRects = function (
this: FragmentInstanceType,
): Array {
const rects: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ collectClientRects,
+ rects,
+ );
return rects;
};
function collectClientRects(child: Fiber, rects: Array): boolean {
@@ -3356,12 +3384,15 @@ FragmentInstance.prototype.getRootNode = function (
this: FragmentInstanceType,
getRootNodeOptions?: {composed: boolean},
): Document | ShadowRoot | FragmentInstanceType {
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return this;
}
- const parentHostInstance =
- getInstanceFromHostFiber(parentHostFiber);
+ const parentHostInstance = getInstanceFromHostFiber(
+ parentHostFiber,
+ );
const rootNode =
// $FlowFixMe[incompatible-type] Flow expects Node
parentHostInstance.getRootNode(getRootNodeOptions) as Document | ShadowRoot;
@@ -3372,14 +3403,21 @@ FragmentInstance.prototype.compareDocumentPosition = function (
this: FragmentInstanceType,
otherNode: Instance,
): number {
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return Node.DOCUMENT_POSITION_DISCONNECTED;
}
const children: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
- const parentHostInstance =
- getInstanceFromHostFiber(parentHostFiber);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ collectChildren,
+ children,
+ );
+ const parentHostInstance = getInstanceFromHostFiber(
+ parentHostFiber,
+ );
if (children.length === 0) {
return compareDocumentPositionForEmptyFragment(
@@ -3390,8 +3428,10 @@ FragmentInstance.prototype.compareDocumentPosition = function (
);
}
- const firstNode = getInstanceFromHostFiber(children[0]);
- const lastNode = getInstanceFromHostFiber(
+ const firstNode = getInstanceFromHostFiber(
+ children[0],
+ );
+ const lastNode = getInstanceFromHostFiber(
children[children.length - 1],
);
@@ -3510,6 +3550,19 @@ function validateDocumentPositionWithFiberTree(
return false;
}
+function scrollTextNodeIntoView(
+ textNode: TextInstance,
+ resolvedAlignToTop: boolean,
+): void {
+ const range = textNode.ownerDocument.createRange();
+ range.selectNodeContents(textNode);
+ const rect = range.getBoundingClientRect();
+ const scrollY = resolvedAlignToTop
+ ? window.scrollY + rect.top
+ : window.scrollY + rect.bottom - window.innerHeight;
+ window.scrollTo(window.scrollX + rect.left, scrollY);
+}
+
if (enableFragmentRefsScrollIntoView) {
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.scrollIntoView = function (
@@ -3524,17 +3577,23 @@ if (enableFragmentRefsScrollIntoView) {
}
// First, get the children nodes
const children: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ collectChildren,
+ children,
+ );
const resolvedAlignToTop = alignToTop !== false;
// If there are no children, we can use the parent and siblings to determine a position
if (children.length === 0) {
- const hostSiblings = getFragmentInstanceSiblings(this._fragmentFiber);
+ const hostSiblings = getFragmentInstanceOrTextInstanceSiblings(
+ this._fragmentFiber,
+ );
const targetFiber = resolvedAlignToTop
? hostSiblings[1] ||
hostSiblings[0] ||
- getFragmentParentHostFiber(this._fragmentFiber)
+ getFragmentParentInstanceOrContainerFiber(this._fragmentFiber)
: hostSiblings[0] || hostSiblings[1];
if (targetFiber === null) {
@@ -3546,7 +3605,19 @@ if (enableFragmentRefsScrollIntoView) {
}
return;
}
- const target = getInstanceFromHostFiber(targetFiber);
+ // For text node siblings, use Range API to scroll to their position
+ if (enableFragmentRefsTextNodes && targetFiber.tag === HostText) {
+ const textNode = getInstanceFromHostFiber(targetFiber);
+ scrollTextNodeIntoView(textNode, resolvedAlignToTop);
+ return;
+ }
+ const target = getInstanceFromHostFiber(
+ targetFiber,
+ );
+ // TODO: If the parent host fiber is a HostRoot, the target is a
+ // Container which can be a Document or DocumentFragment. Those have no
+ // scrollIntoView method, so this crashes at runtime.
+ // $FlowFixMe[prop-missing]
target.scrollIntoView(alignToTop);
return;
}
@@ -3556,14 +3627,8 @@ if (enableFragmentRefsScrollIntoView) {
const child = children[i];
// For text nodes, use Range API to scroll to their position
if (enableFragmentRefsTextNodes && child.tag === HostText) {
- const textNode: Text = child.stateNode;
- const range = textNode.ownerDocument.createRange();
- range.selectNodeContents(textNode);
- const rect = range.getBoundingClientRect();
- const scrollY = resolvedAlignToTop
- ? window.scrollY + rect.top
- : window.scrollY + rect.bottom - window.innerHeight;
- window.scrollTo(window.scrollX + rect.left, scrollY);
+ const textNode = getInstanceFromHostFiber(child);
+ scrollTextNodeIntoView(textNode, resolvedAlignToTop);
i += resolvedAlignToTop ? -1 : 1;
continue;
}
@@ -3579,17 +3644,16 @@ function addFragmentHandleToFiber(
fragmentInstance: FragmentInstanceType,
): boolean {
if (enableFragmentRefsInstanceHandles) {
- const instance =
- getInstanceFromHostFiber(child);
- if (instance != null) {
- addFragmentHandleToInstance(instance, fragmentInstance);
- }
+ const instance = getInstanceFromHostFiber(
+ child,
+ ) as any as HostNodeWithFragmentHandles;
+ addFragmentHandleToInstance(instance, fragmentInstance);
}
return false;
}
function addFragmentHandleToInstance(
- instance: InstanceWithFragmentHandles,
+ instance: HostNodeWithFragmentHandles,
fragmentInstance: FragmentInstanceType,
): void {
if (enableFragmentRefsInstanceHandles) {
@@ -3605,7 +3669,7 @@ export function createFragmentInstance(
): FragmentInstanceType {
const fragmentInstance = new (FragmentInstance as any)(fragmentFiber);
if (enableFragmentRefsInstanceHandles) {
- traverseFragmentInstance(
+ traverseFragmentInstancesAndTextInstances(
fragmentFiber,
addFragmentHandleToFiber,
fragmentInstance,
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index 60515824dab..40a7f855a9f 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
@@ -36,6 +36,7 @@ import {
enableSrcObject,
enableFizzBlockingRender,
enableViewTransition,
+ enableViewTransitionParentEnterExit,
} from 'shared/ReactFeatureFlags';
import type {
@@ -790,6 +791,8 @@ const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense
const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter"
const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation.
+const PARENT_EXIT_SCOPE = /* */ 0b10000000; // Below a ViewTransition that is exiting.
+const PARENT_ENTER_SCOPE = /* */ 0b100000000; // Below a ViewTransition that is entering.
// Everything not listed here are tracked for the whole subtree as opposed to just
// until the next Instance.
@@ -800,6 +803,8 @@ type ViewTransitionContext = {
enter: 'none' | 'auto' | string,
exit: 'none' | 'auto' | string,
share: 'none' | 'auto' | string,
+ parentEnter: 'none' | 'auto' | string,
+ parentExit: 'none' | 'auto' | string,
name: 'auto' | string,
autoName: string, // a name that can be used if an explicit one is not defined.
nameIdx: number, // keeps track of how many duplicates of this name we've emitted.
@@ -957,6 +962,8 @@ function getSuspenseViewTransition(
enter: 'none',
exit: 'none',
share: parentViewTransition.update, // For exit or enter of reveals.
+ parentEnter: 'none',
+ parentExit: 'none',
name: parentViewTransition.autoName,
autoName: parentViewTransition.autoName,
// TOOD: If we have more than just this Suspense boundary as a child of the ViewTransition
@@ -1012,6 +1019,10 @@ export function getViewTransitionFormatContext(
enter: ?string,
exit: ?string,
share: ?string,
+ parentEnter: ?string,
+ parentExit: ?string,
+ hasParentEnterHandler: boolean,
+ hasParentExitHandler: boolean,
name: ?string,
autoName: string, // name or an autogenerated unique name
): FormatContext {
@@ -1058,11 +1069,29 @@ export function getViewTransitionFormatContext(
} else {
resumableState.instructions |= NeedUpgradeToViewTransitions;
}
+ let resolvedParentEnter = 'none';
+ let resolvedParentExit = 'none';
+ if (enableViewTransitionParentEnterExit) {
+ if (
+ parentEnter != null &&
+ (parentContext.tagScope & PARENT_ENTER_SCOPE) !== 0
+ ) {
+ resolvedParentEnter = parentEnter;
+ }
+ if (
+ parentExit != null &&
+ (parentContext.tagScope & PARENT_EXIT_SCOPE) !== 0
+ ) {
+ resolvedParentExit = parentExit;
+ }
+ }
const viewTransition: ViewTransitionContext = {
update,
enter,
exit,
share,
+ parentEnter: resolvedParentEnter,
+ parentExit: resolvedParentExit,
name,
autoName,
nameIdx: 0,
@@ -1076,6 +1105,33 @@ export function getViewTransitionFormatContext(
if (enter !== 'none') {
subtreeScope |= APPEARING_SCOPE;
}
+ if (enableViewTransitionParentEnterExit) {
+ // Parent enter relay: a ViewTransition that is itself entering starts a relay
+ // for its subtree. A nested ViewTransition continues that relay as long as it
+ // opts in with a parentEnter prop that doesn't resolve to "none", or with an
+ // onParentEnter handler. A missing parentEnter (with no handler) or an
+ // explicit "none" stops the relay so descendants below it don't participate.
+ // This mirrors commitParentEnterViewTransitions.
+ if (enter !== 'none') {
+ subtreeScope |= PARENT_ENTER_SCOPE;
+ } else if (
+ (parentContext.tagScope & PARENT_ENTER_SCOPE) !== 0 &&
+ (parentEnter === 'none' ||
+ (parentEnter === undefined && !hasParentEnterHandler))
+ ) {
+ subtreeScope &= ~PARENT_ENTER_SCOPE;
+ }
+ // Parent exit relay: mirror of the enter relay above.
+ if (exit !== 'none') {
+ subtreeScope |= PARENT_EXIT_SCOPE;
+ } else if (
+ (parentContext.tagScope & PARENT_EXIT_SCOPE) !== 0 &&
+ (parentExit === 'none' ||
+ (parentExit === undefined && !hasParentExitHandler))
+ ) {
+ subtreeScope &= ~PARENT_EXIT_SCOPE;
+ }
+ }
return createFormatContext(
parentContext.insertionMode,
parentContext.selectedValue,
@@ -1178,6 +1234,18 @@ function pushViewTransitionAttributes(
if (viewTransition.share !== 'none') {
pushStringAttribute(target, 'vt-share', viewTransition.share);
}
+ if (
+ enableViewTransitionParentEnterExit &&
+ viewTransition.parentEnter !== 'none'
+ ) {
+ pushStringAttribute(target, 'vt-parent-enter', viewTransition.parentEnter);
+ }
+ if (
+ enableViewTransitionParentEnterExit &&
+ viewTransition.parentExit !== 'none'
+ ) {
+ pushStringAttribute(target, 'vt-parent-exit', viewTransition.parentExit);
+ }
}
const styleNameCache: Map = new Map();
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
index 46fad3c39bf..ee5d33d036a 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
@@ -194,6 +194,10 @@ export function getViewTransitionFormatContext(
enter: void | null | 'none' | 'auto' | string,
exit: void | null | 'none' | 'auto' | string,
share: void | null | 'none' | 'auto' | string,
+ parentEnter: void | null | 'none' | 'auto' | string,
+ parentExit: void | null | 'none' | 'auto' | string,
+ hasParentEnterHandler: boolean,
+ hasParentExitHandler: boolean,
name: void | null | 'auto' | string,
autoName: string, // name or an autogenerated unique name
): FormatContext {
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 eca449bc736..62efb120bdc 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&&2E3r&&2E3 {
return hi ;
}
- // Recursively render a component tree deep enough to trigger stack overflow.
- // Don't make this too short to not hit the limit but also not too deep to slow
- // down the test.
+ // Recursively render a component tree deep enough to trigger stack overflow
+ // more than once. The first overflow is recovered by the renderNode
+ // trampoline; deeper trees must also recover when the retried task
+ // overflows again. Don't make this too deep to slow down the test.
await act(() => {
const {pipe} = renderToPipeableStream(
-
+
,
);
pipe(writable);
@@ -7729,6 +7730,94 @@ describe('ReactDOMFizzServer', () => {
expect(caughtError.message).toBe('Maximum call stack size exceeded');
});
+ it('can recover from very deep trees during resume to avoid stack overflow', async () => {
+ const promise = new Promise(() => {});
+
+ let prerendering = true;
+
+ // Deep wrappers above the postponed boundary. On resume, replaying this
+ // path goes through retryReplayTask → retryNode (no trampoline), so a
+ // tree deep enough to overflow must recover there — not only on the
+ // ordinary render retry path.
+ function Deep({n, children}) {
+ if (n > 0) {
+ return {children} ;
+ }
+ return children;
+ }
+
+ function Content() {
+ if (prerendering) {
+ return React.use(promise);
+ }
+ return hi ;
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const controller = new AbortController();
+ const errors = [];
+ let pendingPrerender;
+ await act(() => {
+ pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream( , {
+ signal: controller.signal,
+ onError(error) {
+ errors.push(error);
+ },
+ });
+ });
+ controller.abort('abort');
+
+ const prerendered = await pendingPrerender;
+ expect(errors).toEqual(['abort']);
+ expect(prerendered.postponed).not.toBe(null);
+
+ const preludeWritable = new Stream.PassThrough();
+ preludeWritable.setEncoding('utf8');
+ preludeWritable.on('data', chunk => {
+ writable.write(chunk);
+ });
+
+ await act(() => {
+ prerendered.prelude.pipe(preludeWritable);
+ });
+ expect(getVisibleChildren(container)).toEqual(Loading...
);
+
+ prerendering = false;
+ errors.length = 0;
+
+ const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
+ ,
+ JSON.parse(JSON.stringify(prerendered.postponed)),
+ {
+ onError(error) {
+ errors.push(error);
+ },
+ },
+ );
+
+ await act(() => {
+ resumed.pipe(writable);
+ });
+
+ expect(errors).toEqual([]);
+ expect(getVisibleChildren(container)).toEqual(
+
+ hi
+
,
+ );
+ });
+
it('client renders incomplete Suspense boundaries when the document is no longer loading when hydration begins', async () => {
let resolve;
const promise = new Promise(r => {
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
index 22479ee0ec2..c1b8a509d7c 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
@@ -375,7 +375,7 @@ describe('ReactDOMFizzShellHydration', () => {
expect(container.textContent).toBe('New screen');
});
- it('TODO: A large component stack causes SSR to stack overflow', async () => {
+ it('recovers from a large component stack during SSR', async () => {
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
function NestedComponent({depth}: {depth: number}) {
@@ -385,16 +385,16 @@ describe('ReactDOMFizzShellHydration', () => {
return ;
}
- // Server render
+ await resolveText('Shell');
await serverAct(async () => {
- ReactDOMFizzServer.renderToPipeableStream(
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
,
);
+ pipe(writable);
});
- expect(console.error).toHaveBeenCalledTimes(1);
- expect(console.error.mock.calls[0][0].toString()).toBe(
- 'RangeError: Maximum call stack size exceeded',
- );
+ expect(console.error).not.toHaveBeenCalled();
+ assertLog(['Shell']);
+ expect(container.textContent).toBe('Shell');
});
it('client renders when an error is thrown in an error boundary', async () => {
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
index ee87fdac9ce..8cb161237b7 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
@@ -332,4 +332,419 @@ describe('ReactDOMFizzViewTransition', () => {
ReactDOMClient.hydrateRoot(container, );
});
});
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('stops the parentExit relay when an intermediate class is "none"', async () => {
+ const promise = new Promise(() => {});
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ );
+ return (
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+
+ // The "none" on the wrapper stops the relay, so the deep child (C) never
+ // gets a vt-parent-exit annotation. The sibling (D), which is not behind a
+ // "none" boundary, still relays.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('stops the parentEnter relay when an intermediate class is "none"', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ return (
+
+ loading
}>
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+ await serverAct(async () => {
+ await resolve(
+ ,
+ );
+ });
+
+ // The "none" on the wrapper stops the relay, so the deep child (C) never
+ // gets a vt-parent-enter annotation. The sibling (D) still relays.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ // Hydration should not yield any errors.
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ });
+
+ // @gate enableViewTransition
+ it('breaks the parentExit relay through a ViewTransition without parentExit', async () => {
+ const promise = new Promise(() => {});
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ );
+ return (
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+
+ // A ViewTransition that doesn't opt in with parentExit breaks the relay,
+ // just like the client runtime, so C is not annotated.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('relays the parentExit chain through an "auto" parentExit', async () => {
+ const promise = new Promise(() => {});
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ );
+ return (
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+
+ // An "auto" parentExit emits no annotation of its own but still relays, so
+ // the deep child (C) is annotated.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('relays the parentExit chain through a handler-only ViewTransition', async () => {
+ const promise = new Promise(() => {});
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ );
+ return (
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+
+ // A ViewTransition with only an onParentExit handler (no class) emits no
+ // annotation but still relays, just like the client runtime, so the deep
+ // child (C) is annotated.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('relays the parentEnter chain through a handler-only ViewTransition', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ return (
+
+ loading
}>
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+ await serverAct(async () => {
+ await resolve(
+ ,
+ );
+ });
+
+ // A ViewTransition with only an onParentEnter handler still relays, so the
+ // deep child (C) is annotated.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('applies view-transition-name to nested parentEnter/parentExit on streaming reveal', async () => {
+ // Capture the view transition class applied to each element at the moment
+ // the reveal starts a view transition (the names are reverted once it
+ // begins, so we read them synchronously here).
+ const applied = new Map();
+ document.startViewTransition = function (arg) {
+ const update = typeof arg === 'function' ? arg : arg.update;
+ container.querySelectorAll('*').forEach(el => {
+ if (el.id && el.style && el.style.viewTransitionName) {
+ applied.set(el.id, el.style.viewTransitionClass);
+ }
+ });
+ if (update) {
+ update();
+ }
+ return {
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ skipTransition() {},
+ types: [],
+ };
+ };
+ if (!global.window.CSS) {
+ global.window.CSS = {escape: s => s};
+ }
+ Object.defineProperty(document, 'fonts', {
+ value: {status: 'loaded', ready: Promise.resolve()},
+ configurable: true,
+ });
+ // The reveal skips a boundary whose parent measures as empty, so give
+ // elements a non-zero rect.
+ global.window.Element.prototype.getBoundingClientRect = function () {
+ return {left: 0, top: 0, width: 100, height: 20, right: 100, bottom: 20};
+ };
+
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ function Suspend() {
+ return React.use(promise);
+ }
+ function App() {
+ const fallback = (
+
+
+
+ );
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream( );
+ pipe(writable);
+ });
+ await serverAct(async () => {
+ await resolve(
+ ,
+ );
+ });
+
+ // Top-level enter/exit were already applied by the reveal. The nested parent
+ // relay is the new behavior, and it relays through multiple levels: the
+ // relay continues through C/fbC (non-none classes) down to D/fbD, matching
+ // the client runtime's commitParentEnter/ExitViewTransitions.
+ expect(applied.get('A')).toBe('page-enter');
+ expect(applied.get('C')).toBe('nested-enter');
+ expect(applied.get('D')).toBe('nested-enter-deep');
+ expect(applied.get('fbA')).toBe('page-exit');
+ expect(applied.get('fbC')).toBe('skeleton-exit');
+ expect(applied.get('fbD')).toBe('skeleton-exit-deep');
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
index ae934b914e2..fbc3bdf60c2 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -2755,6 +2755,48 @@ describe('FragmentRefs', () => {
restoreRange();
});
+ // @gate enableFragmentRefs && enableFragmentRefsTextNodes && enableFragmentRefsScrollIntoView
+ it('scrollIntoView scrolls to text siblings of an empty fragment using the Range API', async () => {
+ const restoreRange = mockRangeClientRects([
+ {x: 100, y: 200, width: 80, height: 16},
+ ]);
+ const fragmentRef = React.createRef();
+ const parentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() =>
+ root.render(
+
+ Text before
+
+ Text after
+
,
+ ),
+ );
+
+ const parentScrollMock = jest.fn();
+ parentRef.current.scrollIntoView = parentScrollMock;
+ // Mock window.scrollTo to verify Range-based text scrolling
+ const originalScrollTo = window.scrollTo;
+ const scrollToMock = jest.fn();
+ window.scrollTo = scrollToMock;
+
+ // Default call scrolls to the following text sibling
+ fragmentRef.current.scrollIntoView();
+ expect(scrollToMock).toHaveBeenCalledTimes(1);
+ expect(parentScrollMock).toHaveBeenCalledTimes(0);
+
+ scrollToMock.mockClear();
+
+ // alignToTop=false scrolls to the preceding text sibling
+ fragmentRef.current.scrollIntoView(false);
+ expect(scrollToMock).toHaveBeenCalledTimes(1);
+ expect(parentScrollMock).toHaveBeenCalledTimes(0);
+
+ window.scrollTo = originalScrollTo;
+ restoreRange();
+ });
+
// @gate enableFragmentRefs
it('treats passive:true and passive:false as same listener per DOM spec', async () => {
const fragmentRef = React.createRef();
diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js
index d12d72e69e0..2df83b68b9e 100644
--- a/packages/react-markup/src/ReactFizzConfigMarkup.js
+++ b/packages/react-markup/src/ReactFizzConfigMarkup.js
@@ -95,6 +95,10 @@ export function getViewTransitionFormatContext(
enter: void | null | 'none' | 'auto' | string,
exit: void | null | 'none' | 'auto' | string,
share: void | null | 'none' | 'auto' | string,
+ parentEnter: void | null | 'none' | 'auto' | string,
+ parentExit: void | null | 'none' | 'auto' | string,
+ hasParentEnterHandler: boolean,
+ hasParentExitHandler: boolean,
name: void | null | 'auto' | string,
autoName: string, // name or an autogenerated unique name
): FormatContext {
diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js
index 5310a13ab48..bf2ada73dad 100644
--- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js
+++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js
@@ -24,8 +24,8 @@ import {
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import {HostText} from 'react-reconciler/src/ReactWorkTags';
import {
- getFragmentParentHostFiber,
- traverseFragmentInstance,
+ getFragmentParentInstanceOrContainerFiber,
+ traverseFragmentInstancesAndTextInstances,
} from 'react-reconciler/src/ReactFiberTreeReflection';
// Modules provided by RN:
@@ -704,7 +704,11 @@ FragmentInstance.prototype.observeUsing = function (
this._observers = new Set();
}
this._observers.add(observer);
- traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ observeChild,
+ observer,
+ );
};
function observeChild(child: Fiber, observer: IntersectionObserver) {
// $FlowFixMe[incompatible-type]
@@ -728,7 +732,11 @@ FragmentInstance.prototype.unobserveUsing = function (
}
} else {
observers.delete(observer);
- traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ unobserveChild,
+ observer,
+ );
}
};
function unobserveChild(child: Fiber, observer: IntersectionObserver) {
@@ -744,12 +752,18 @@ FragmentInstance.prototype.compareDocumentPosition = function (
this: FragmentInstanceType,
otherNode: PublicInstance,
): number {
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return Node.DOCUMENT_POSITION_DISCONNECTED;
}
const children: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ collectChildren,
+ children,
+ );
if (children.length === 0) {
const parentHostInstance = getPublicInstanceFromHostFiber(parentHostFiber);
return compareDocumentPositionForEmptyFragment(
@@ -804,7 +818,9 @@ FragmentInstance.prototype.getRootNode = function (
this: FragmentInstanceType,
getRootNodeOptions?: {composed: boolean},
): Node | FragmentInstanceType {
- const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(
+ this._fragmentFiber,
+ );
if (parentHostFiber === null) {
return this;
}
@@ -819,7 +835,11 @@ FragmentInstance.prototype.getClientRects = function (
this: FragmentInstanceType,
): Array {
const rects: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
+ traverseFragmentInstancesAndTextInstances(
+ this._fragmentFiber,
+ collectClientRects,
+ rects,
+ );
return rects;
};
function collectClientRects(child: Fiber, rects: Array): boolean {
@@ -867,7 +887,7 @@ export function createFragmentInstance(
): FragmentInstanceType {
const fragmentInstance = new (FragmentInstance as any)(fragmentFiber);
if (enableFragmentRefsInstanceHandles) {
- traverseFragmentInstance(
+ traverseFragmentInstancesAndTextInstances(
fragmentFiber,
addFragmentHandleToFiber,
fragmentInstance,
diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js
index 8d8766ae4d5..9d3d3020268 100644
--- a/packages/react-reconciler/src/ReactFiberTreeReflection.js
+++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js
@@ -10,6 +10,8 @@
import type {Fiber} from './ReactInternalTypes';
import type {
Container,
+ Instance,
+ TextInstance,
ActivityInstance,
SuspenseInstance,
} from './ReactFiberConfig';
@@ -345,27 +347,41 @@ export function doesFiberContain(
return false;
}
-export function traverseFragmentInstance(
+export function traverseFragmentInstancesAndTextInstances (
fragmentFiber: Fiber,
fn: (Fiber, A, B, C) => boolean,
a: A,
b: B,
c: C,
): void {
- traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
+ traverseVisibleInstancesAndTextInstances(
+ fragmentFiber.child,
+ false,
+ fn,
+ a,
+ b,
+ c,
+ );
}
-export function traverseFragmentInstanceDeeply (
+export function traverseFragmentInstancesAndTextInstancesDeeply (
fragmentFiber: Fiber,
fn: (Fiber, A, B, C) => boolean,
a: A,
b: B,
c: C,
): void {
- traverseVisibleHostChildren(fragmentFiber.child, true, fn, a, b, c);
+ traverseVisibleInstancesAndTextInstances(
+ fragmentFiber.child,
+ true,
+ fn,
+ a,
+ b,
+ c,
+ );
}
-function traverseVisibleHostChildren (
+function traverseVisibleInstancesAndTextInstances (
child: Fiber | null,
searchWithinHosts: boolean,
fn: (Fiber, A, B, C) => boolean,
@@ -387,7 +403,14 @@ function traverseVisibleHostChildren (
} else {
if (
(searchWithinHosts || child.tag !== HostComponent) &&
- traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
+ traverseVisibleInstancesAndTextInstances(
+ child.child,
+ searchWithinHosts,
+ fn,
+ a,
+ b,
+ c,
+ )
) {
return true;
}
@@ -397,7 +420,9 @@ function traverseVisibleHostChildren (
return false;
}
-export function getFragmentParentHostFiber(fiber: Fiber): null | Fiber {
+export function getFragmentParentInstanceOrContainerFiber(
+ fiber: Fiber,
+): null | Fiber {
let parent = fiber.return;
while (parent !== null) {
if (parent.tag === HostRoot || parent.tag === HostComponent) {
@@ -424,20 +449,27 @@ export function fiberIsPortaledIntoHost(fiber: Fiber): boolean {
return foundPortalParent;
}
-export function getFragmentInstanceSiblings(
+export function getFragmentInstanceOrTextInstanceSiblings(
fiber: Fiber,
): [Fiber | null, Fiber | null] {
const result: [Fiber | null, Fiber | null] = [null, null];
- const parentHostFiber = getFragmentParentHostFiber(fiber);
+ const parentHostFiber = getFragmentParentInstanceOrContainerFiber(fiber);
if (parentHostFiber === null) {
return result;
}
- findFragmentInstanceSiblings(result, fiber, parentHostFiber.child);
+ findFragmentInstanceOrTextInstanceSiblings(
+ result,
+ fiber,
+ parentHostFiber.child,
+ );
return result;
}
-function findFragmentInstanceSiblings(
+/**
+ * Only collects HostText with enableFragmentRefsTextNodes enabled. Otherwise, only collects HostComponent.
+ */
+function findFragmentInstanceOrTextInstanceSiblings(
result: [Fiber | null, Fiber | null],
self: Fiber,
child: null | Fiber,
@@ -452,7 +484,10 @@ function findFragmentInstanceSiblings(
return true;
}
}
- if (child.tag === HostComponent) {
+ if (
+ child.tag === HostComponent ||
+ (enableFragmentRefsTextNodes && child.tag === HostText)
+ ) {
if (foundSelf) {
result[1] = child;
return true;
@@ -465,7 +500,14 @@ function findFragmentInstanceSiblings(
) {
// Skip hidden subtrees
} else {
- if (findFragmentInstanceSiblings(result, self, child.child, foundSelf)) {
+ if (
+ findFragmentInstanceOrTextInstanceSiblings(
+ result,
+ self,
+ child.child,
+ foundSelf,
+ )
+ ) {
return true;
}
}
@@ -474,7 +516,9 @@ function findFragmentInstanceSiblings(
return false;
}
-export function getInstanceFromHostFiber(fiber: Fiber): I {
+export function getInstanceFromHostFiber<
+ I: Instance | TextInstance | Container,
+>(fiber: Fiber): I {
switch (fiber.tag) {
case HostComponent:
case HostText:
@@ -501,8 +545,14 @@ function popSearchBoundary(): null | Fiber {
return searchBoundary;
}
-export function getNextSiblingHostFiber(fiber: Fiber): null | Fiber {
- traverseVisibleHostChildren(fiber.sibling, false, findNextSibling);
+export function getNextSiblingInstanceOrTextInstanceFiber(
+ fiber: Fiber,
+): null | Fiber {
+ traverseVisibleInstancesAndTextInstances(
+ fiber.sibling,
+ false,
+ findNextSibling,
+ );
const sibling = popSearchTarget();
pushSearchTarget(null);
return sibling;
@@ -536,7 +586,7 @@ export function isFragmentContainedByFiber(
): boolean {
let current: Fiber | null = fragmentFiber;
const fiberHostParent: Fiber | null =
- getFragmentParentHostFiber(fragmentFiber);
+ getFragmentParentInstanceOrContainerFiber(fragmentFiber);
while (current !== null) {
if (
(current.tag === HostComponent || current.tag === HostRoot) &&
@@ -558,7 +608,7 @@ export function isFiberPreceding(fiber: Fiber, otherFiber: Fiber): boolean {
if (commonAncestor === null) {
return false;
}
- traverseVisibleHostChildren(
+ traverseVisibleInstancesAndTextInstances(
commonAncestor,
true,
isFiberPrecedingCheck,
@@ -594,7 +644,7 @@ export function isFiberFollowing(fiber: Fiber, otherFiber: Fiber): boolean {
if (commonAncestor === null) {
return false;
}
- traverseVisibleHostChildren(
+ traverseVisibleInstancesAndTextInstances(
commonAncestor,
true,
isFiberFollowingCheck,
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index a9a594dd0c6..9a03f202630 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -181,6 +181,7 @@ import {
enableScopeAPI,
enableAsyncIterableChildren,
enableViewTransition,
+ enableViewTransitionParentEnterExit,
enableFizzBlockingRender,
enableAsyncDebugInfo,
enableCPUSuspense,
@@ -2953,6 +2954,20 @@ function renderViewTransition(
getViewTransitionClassName(props.default, props.enter),
getViewTransitionClassName(props.default, props.exit),
getViewTransitionClassName(props.default, props.share),
+ // Pass `undefined` (rather than the resolved class) when the prop is absent
+ // so the format context can distinguish "no parentEnter/parentExit" (which
+ // stops the relay) from an explicit "auto"/class (which continues it).
+ enableViewTransitionParentEnterExit && props.parentEnter !== undefined
+ ? getViewTransitionClassName(props.default, props.parentEnter)
+ : undefined,
+ enableViewTransitionParentEnterExit && props.parentExit !== undefined
+ ? getViewTransitionClassName(props.default, props.parentExit)
+ : undefined,
+ // A ViewTransition with an onParentEnter/onParentExit handler but no class
+ // still relays the activation to its descendants, so the relay must continue
+ // through it even though the handler itself emits no annotation.
+ enableViewTransitionParentEnterExit && props.onParentEnter != null,
+ enableViewTransitionParentEnterExit && props.onParentExit != null,
props.name,
autoName,
);
@@ -3213,7 +3228,10 @@ function replayElement(
if (
typeof x === 'object' &&
x !== null &&
- (x === SuspenseException || typeof x.then === 'function')
+ (x === SuspenseException ||
+ typeof x.then === 'function' ||
+ // Rethrow so retryReplayTask can trampoline on stack overflow.
+ x.message === 'Maximum call stack size exceeded')
) {
// Suspend
if (task.node === currentNode) {
@@ -5239,6 +5257,8 @@ function retryRenderTask(
const childrenLength = segment.children.length;
const chunkLength = segment.chunks.length;
+ // Used to detect forward progress if we hit a stack overflow below.
+ const startNode = task.node;
try {
// We call the destructive form that mutates this task. That way if something
// suspends again, we can reuse the same task instead of spawning a new one.
@@ -5303,6 +5323,16 @@ function retryRenderTask(
(x as any).then(ping.resolve, ping.reject);
return;
}
+ if (
+ x.message === 'Maximum call stack size exceeded' &&
+ task.node !== startNode
+ ) {
+ segment.status = PENDING;
+ task.thenableState = null;
+ // Immediately schedule the task for retrying.
+ request.pingedTasks.push(task);
+ return;
+ }
}
const errorInfo = getThrownInfo(task.componentStack);
@@ -5345,6 +5375,8 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
setCurrentTaskInDEV(task);
}
+ // Used to detect forward progress if we hit a stack overflow below.
+ const startNode = task.node;
try {
// We call the destructive form that mutates this task. That way if something
// suspends again, we can reuse the same task instead of spawning a new one.
@@ -5408,6 +5440,17 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
: null;
return;
}
+ if (
+ x.message === 'Maximum call stack size exceeded' &&
+ task.node !== startNode
+ ) {
+ // Stack overflow after making forward progress. Retry from a fresh stack.
+ // No progress (e.g. overflow inside the component itself) falls through.
+ task.thenableState = null;
+ // Immediately schedule the task for retrying.
+ request.pingedTasks.push(task);
+ return;
+ }
}
task.replay.pendingTasks--;
task.abortSet.delete(task);
diff --git a/packages/shared/ReactDOMFragmentRefShared.js b/packages/shared/ReactDOMFragmentRefShared.js
index c46d67d4114..6478c6be740 100644
--- a/packages/shared/ReactDOMFragmentRefShared.js
+++ b/packages/shared/ReactDOMFragmentRefShared.js
@@ -11,7 +11,7 @@
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
-import {getNextSiblingHostFiber} from 'react-reconciler/src/ReactFiberTreeReflection';
+import {getNextSiblingInstanceOrTextInstanceFiber} from 'react-reconciler/src/ReactFiberTreeReflection';
export function compareDocumentPositionForEmptyFragment(
fragmentFiber: Fiber,
@@ -32,7 +32,8 @@ export function compareDocumentPositionForEmptyFragment(
if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
// otherNode is one of the fragment's siblings. Use the next
// sibling to determine if its preceding or following.
- const nextSiblingFiber = getNextSiblingHostFiber(fragmentFiber);
+ const nextSiblingFiber =
+ getNextSiblingInstanceOrTextInstanceFiber(fragmentFiber);
if (nextSiblingFiber === null) {
result = Node.DOCUMENT_POSITION_PRECEDING;
} else {