From 689a4fa44169f945a32308959f42e852fbb66017 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Sun, 19 Jul 2026 07:13:40 -1000 Subject: [PATCH 1/3] [Fizz] Extend stack overflow recovery to retries (#36977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran into this test failure as part of https://github.com/react/react/pull/36917 - it seems that the added code was just enough to increase stack size and fail the deep tree recovery test in CI. Looking into that, there appears to be a gap here with retries, including a TODO test case for the scenario. Fizz recovers from stack overflows in extremely deep trees by catching the first overflow in the `renderNode` trampoline and spawning a continuation task. That continuation is retried via `retryRenderTask → retryNode`, which has no trampoline above it. So if the remaining tree still doesn't fit in one fresh stack, the overflow was treated as a fatal error instead of recovering again. This fix re-schedules the task when a retried render overflows but `task.node` advanced (proving forward progress was made). If this is a real in-component overflow, `task.node` doesn't advance and we still fail. The existing test used `n={1000}`, which only required one recovery round and didn't catch this gap in source mode. It's updated to `n={1200}`, which reliably requires multiple recovery rounds. --- .../src/__tests__/ReactDOMFizzServer-test.js | 97 ++++++++++++++++++- .../ReactDOMFizzShellHydration-test.js | 14 +-- packages/react-server/src/ReactFizzServer.js | 32 +++++- 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 161f9a10114..8ad9df6f78d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -7676,13 +7676,14 @@ describe('ReactDOMFizzServer', () => { 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-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a9a594dd0c6..ca0d5409718 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -3213,7 +3213,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 +5242,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 +5308,18 @@ function retryRenderTask( (x as any).then(ping.resolve, ping.reject); 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. + segment.status = PENDING; + task.thenableState = null; + // Immediately schedule the task for retrying. + request.pingedTasks.push(task); + return; + } } const errorInfo = getThrownInfo(task.componentStack); @@ -5345,6 +5362,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 +5427,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); From 2ba07c6d52ba851ed896a50477d99aa7b70c7a08 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Sun, 19 Jul 2026 21:41:37 +0200 Subject: [PATCH 2/3] [DOM] Scroll to text siblings of empty Fragments instead of the parent (#37060) When a Fragment has no children, React would consider scrolling to siblings first and then to parents. However, React only considered `HostComponent` for the siblings. Since we already have a heuristic for scrolling to `HostText`, we can reuse that same heuristic. --------- Co-authored-by: Claude Fable 5 --- .../src/client/ReactFiberConfigDOM.js | 184 ++++++++++++------ .../__tests__/ReactDOMFragmentRefs-test.js | 42 ++++ .../src/ReactFiberConfigFabric.js | 38 +++- .../src/ReactFiberTreeReflection.js | 88 +++++++-- packages/shared/ReactDOMFragmentRefShared.js | 5 +- 5 files changed, 267 insertions(+), 90 deletions(-) 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/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-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/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 { From 83840902c890f0eb85decda239ef6b1b14945779 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Sun, 19 Jul 2026 09:59:13 -1000 Subject: [PATCH 3/3] [Fizz] Support nested enter/exit ViewTransition animations (#36917) Adds SSR support for nested parentEnter/parentExit View Transitions. Fizz now emits vt-parent-enter/vt-parent-exit annotations during streaming, and the client picks them up on hydration, so nested enter/exit animations work for Suspense reveals. --- fixtures/view-transition/server/render.js | 7 + .../src/components/NestedParentExit.css | 49 +++ .../src/components/NestedParentExit.js | 72 ++- .../src/components/NestedReveal.js | 5 + .../view-transition/src/components/Page.css | 43 +- .../view-transition/src/components/Page.js | 216 ++++----- .../src/client/ReactDOMComponent.js | 8 +- .../src/server/ReactFizzConfigDOM.js | 68 +++ .../src/server/ReactFizzConfigDOMLegacy.js | 4 + ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 12 + .../ReactDOMFizzViewTransition-test.js | 415 ++++++++++++++++++ .../react-markup/src/ReactFizzConfigMarkup.js | 4 + packages/react-server/src/ReactFizzServer.js | 17 +- 14 files changed, 807 insertions(+), 115 deletions(-) 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'}> - - -
- -
- -

{!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'}> + + +
+
- {b} - {a} + +

{!show ? 'A' : 'B' + counter}

+
- ) - } - - {show ? ( -
hello{exclamation}
- ) : ( -
Loading
- )} -
-

- -

- {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}
+ ) : ( +
Loading
+ )}
-
- + +

+ {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/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 { 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 = ( + +
+ +
+ +
Deep
+
+
+
+ + Shallow + +
+
+ ); + 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( +
+
+
+
+ Deep +
+
+ + Shallow + +
+
, + ); + }); + + // @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( +
+ +
+ +
Deep
+
+
+
+ + Shallow + +
, + ); + }); + + // 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( +
+
+
+
+ Deep +
+
+ + Shallow + +
+
, + ); + + // 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 = ( + +
+ +
+ +
Deep
+
+
+
+
+
+ ); + 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( +
+
+
+
+ Deep +
+
+
+
, + ); + }); + + // @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 = ( + +
+ +
+ +
Deep
+
+
+
+
+
+ ); + 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( +
+
+
+
+ Deep +
+
+
+
, + ); + }); + + // @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 = ( + +
+ {}}> +
+ +
Deep
+
+
+
+
+
+ ); + 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( +
+
+
+
+ Deep +
+
+
+
, + ); + }); + + // @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( +
+ {}}> +
+ +
Deep
+
+
+
+
, + ); + }); + + // A ViewTransition with only an onParentEnter handler still relays, so the + // deep child (C) is annotated. + expect(getVisibleChildren(container)).toEqual( +
+
+
+
+ Deep +
+
+
+
, + ); + }); + + // @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 = ( + +
+ +
+ +
Loading
+
+
+
+
+
+ ); + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await serverAct(async () => { + await resolve( +
+ +
+ +
Deep
+
+
+
+
, + ); + }); + + // 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-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-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index ca0d5409718..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, ); @@ -5312,8 +5327,6 @@ function retryRenderTask( 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. segment.status = PENDING; task.thenableState = null; // Immediately schedule the task for retrying.