From f93b9fd44b576c1e0233f854cd986cbf08b7a5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 17 Dec 2025 09:37:43 -0500 Subject: [PATCH 1/2] Skip hydration errors when a view transition has been applied (#35380) When the Fizz runtime runs a view-transition we apply `view-transition-name` and `view-transition-class` to the `style`. These can be observed by Fiber when hydrating which incorrectly leads to hydration errors. More over, even after we remove them, the `style` attribute has now been normalized which we are unable to diff because we diff against the SSR generated `style` attribute string and not the normalized form. So if there are other inline styles defined, we have to skip diffing them in this scenario. --- .../src/client/ReactDOMComponent.js | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 549b279f1da..1b25e372702 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -235,6 +235,31 @@ function warnForPropDifference( } } +function hasViewTransition(htmlElement: HTMLElement): boolean { + return !!( + htmlElement.getAttribute('vt-share') || + htmlElement.getAttribute('vt-exit') || + htmlElement.getAttribute('vt-enter') || + htmlElement.getAttribute('vt-update') + ); +} + +function isExpectedViewTransitionName(htmlElement: HTMLElement): boolean { + if (!hasViewTransition(htmlElement)) { + // We didn't expect to see a view transition name applied. + return false; + } + const expectedVtName = htmlElement.getAttribute('vt-name'); + const actualVtName: string = (htmlElement.style: any)['view-transition-name']; + if (expectedVtName) { + return expectedVtName === actualVtName; + } else { + // Auto-generated name. + // TODO: If Fizz starts applying a prefix to this name, we need to consider that. + return actualVtName.startsWith('_T_'); + } +} + function warnForExtraAttributes( domElement: Element, attributeNames: Set, @@ -242,10 +267,28 @@ function warnForExtraAttributes( ) { if (__DEV__) { attributeNames.forEach(function (attributeName) { - serverDifferences[getPropNameFromAttributeName(attributeName)] = - attributeName === 'style' - ? getStylesObjectFromElement(domElement) - : domElement.getAttribute(attributeName); + if (attributeName === 'style') { + if (domElement.getAttribute(attributeName) === '') { + // Skip empty style. It's fine. + return; + } + const htmlElement = ((domElement: any): HTMLElement); + const style = htmlElement.style; + const isOnlyVTStyles = + (style.length === 1 && style[0] === 'view-transition-name') || + (style.length === 2 && + style[0] === 'view-transition-class' && + style[1] === 'view-transition-name'); + if (isOnlyVTStyles && isExpectedViewTransitionName(htmlElement)) { + // If the only extra style was the view-transition-name that we applied from the Fizz + // runtime, then we should ignore it. + } else { + serverDifferences.style = getStylesObjectFromElement(domElement); + } + } else { + serverDifferences[getPropNameFromAttributeName(attributeName)] = + domElement.getAttribute(attributeName); + } }); } } @@ -1977,13 +2020,21 @@ function getStylesObjectFromElement(domElement: Element): { [styleName: string]: string, } { const serverValueInObjectForm: {[prop: string]: string} = {}; - const style = ((domElement: any): HTMLElement).style; + const htmlElement: HTMLElement = (domElement: any); + const style = htmlElement.style; for (let i = 0; i < style.length; i++) { const styleName: string = style[i]; // TODO: We should use the original prop value here if it is equivalent. // TODO: We could use the original client capitalization if the equivalent // other capitalization exists in the DOM. - serverValueInObjectForm[styleName] = style.getPropertyValue(styleName); + if ( + styleName === 'view-transition-name' && + isExpectedViewTransitionName(htmlElement) + ) { + // This is a view transition name added by the Fizz runtime, not the user's props. + } else { + serverValueInObjectForm[styleName] = style.getPropertyValue(styleName); + } } return serverValueInObjectForm; } @@ -2018,6 +2069,20 @@ function diffHydratedStyles( return; } + if ( + // Trailing semi-colon means this was regenerated. + normalizedServerValue[normalizedServerValue.length - 1] === ';' && + // TODO: Should we just ignore any style if the style as been manipulated? + hasViewTransition((domElement: any)) + ) { + // If this had a view transition we might have applied a view transition + // name/class and removed it. If that happens, the style attribute gets + // regenerated from the style object. This means we've lost the format + // that we sent from the server and is unable to diff it. We just treat + // it as passing even if it should be a mismatch in this edge case. + return; + } + // Otherwise, we create the object from the DOM for the diff view. serverDifferences.style = getStylesObjectFromElement(domElement); } From 454fc41fc7d50f8abbcfb9595b01e8ea8bfcc265 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 17 Dec 2025 18:08:16 +0100 Subject: [PATCH 2/2] [test] Add tests for cyclic arrays in Flight and Flight Reply (#35347) We already had tests for cyclic objects, but not for cyclic arrays. --- .../src/__tests__/ReactFlight-test.js | 19 +++++++++++++++++++ .../src/__tests__/ReactFlightDOMReply-test.js | 11 +++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index b2dcd69899b..b4a07239296 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -724,6 +724,25 @@ describe('ReactFlight', () => { }); }); + it('can transport cyclic arrays', async () => { + function ComponentClient({prop, obj}) { + expect(prop[1]).toBe(prop); + expect(prop[0]).toBe(obj); + } + const Component = clientReference(ComponentClient); + + const obj = {}; + const cyclic = [obj]; + cyclic[1] = cyclic; + const model = ; + + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + }); + it('can render a lazy component as a shared component on the server', async () => { function SharedComponent({text}) { return ( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index 2181eb5fe70..409718973be 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -650,6 +650,17 @@ describe('ReactFlightDOMReply', () => { expect(root.prop.obj).toBe(root.prop); }); + it('can transport cyclic arrays', async () => { + const obj = {}; + const cyclic = [obj]; + cyclic[1] = cyclic; + + const body = await ReactServerDOMClient.encodeReply({prop: cyclic, obj}); + const root = await ReactServerDOMServer.decodeReply(body, webpackServerMap); + expect(root.prop[1]).toBe(root.prop); + expect(root.prop[0]).toBe(root.obj); + }); + it('can abort an unresolved model and get the partial result', async () => { const promise = new Promise(r => {}); const controller = new AbortController();