@samitouri / QOS-React-1 / commits / f93b9fd44b

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.

Sebastian Markbåge committed Dec 17, 2025 at 09:37 UTC f93b9fd44b576c1e0233f854cd986cbf08b7a5c4
1 file changed +71 -6
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+71 -6
@@ -235,6 +235,31 @@ function warnForPropDifference(
235 }
236 }
237
238 +function hasViewTransition(htmlElement: HTMLElement): boolean {
239 + return !!(
240 + htmlElement.getAttribute('vt-share') ||
241 + htmlElement.getAttribute('vt-exit') ||
242 + htmlElement.getAttribute('vt-enter') ||
243 + htmlElement.getAttribute('vt-update')
244 + );
245 +}
246 +
247 +function isExpectedViewTransitionName(htmlElement: HTMLElement): boolean {
248 + if (!hasViewTransition(htmlElement)) {
249 + // We didn't expect to see a view transition name applied.
250 + return false;
251 + }
252 + const expectedVtName = htmlElement.getAttribute('vt-name');
253 + const actualVtName: string = (htmlElement.style: any)['view-transition-name'];
254 + if (expectedVtName) {
255 + return expectedVtName === actualVtName;
256 + } else {
257 + // Auto-generated name.
258 + // TODO: If Fizz starts applying a prefix to this name, we need to consider that.
259 + return actualVtName.startsWith('_T_');
260 + }
261 +}
262 +
263 function warnForExtraAttributes(
264 domElement: Element,
265 attributeNames: Set<string>,
@@ -242,10 +267,28 @@ function warnForExtraAttributes(
267 ) {
268 if (__DEV__) {
269 attributeNames.forEach(function (attributeName) {
245 - serverDifferences[getPropNameFromAttributeName(attributeName)] =
246 - attributeName === 'style'
247 - ? getStylesObjectFromElement(domElement)
248 - : domElement.getAttribute(attributeName);
270 + if (attributeName === 'style') {
271 + if (domElement.getAttribute(attributeName) === '') {
272 + // Skip empty style. It's fine.
273 + return;
274 + }
275 + const htmlElement = ((domElement: any): HTMLElement);
276 + const style = htmlElement.style;
277 + const isOnlyVTStyles =
278 + (style.length === 1 && style[0] === 'view-transition-name') ||
279 + (style.length === 2 &&
280 + style[0] === 'view-transition-class' &&
281 + style[1] === 'view-transition-name');
282 + if (isOnlyVTStyles && isExpectedViewTransitionName(htmlElement)) {
283 + // If the only extra style was the view-transition-name that we applied from the Fizz
284 + // runtime, then we should ignore it.
285 + } else {
286 + serverDifferences.style = getStylesObjectFromElement(domElement);
287 + }
288 + } else {
289 + serverDifferences[getPropNameFromAttributeName(attributeName)] =
290 + domElement.getAttribute(attributeName);
291 + }
292 });
293 }
294 }
@@ -1977,13 +2020,21 @@ function getStylesObjectFromElement(domElement: Element): {
2020 [styleName: string]: string,
2021 } {
2022 const serverValueInObjectForm: {[prop: string]: string} = {};
1980 - const style = ((domElement: any): HTMLElement).style;
2023 + const htmlElement: HTMLElement = (domElement: any);
2024 + const style = htmlElement.style;
2025 for (let i = 0; i < style.length; i++) {
2026 const styleName: string = style[i];
2027 // TODO: We should use the original prop value here if it is equivalent.
2028 // TODO: We could use the original client capitalization if the equivalent
2029 // other capitalization exists in the DOM.
1986 - serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
2030 + if (
2031 + styleName === 'view-transition-name' &&
2032 + isExpectedViewTransitionName(htmlElement)
2033 + ) {
2034 + // This is a view transition name added by the Fizz runtime, not the user's props.
2035 + } else {
2036 + serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
2037 + }
2038 }
2039 return serverValueInObjectForm;
2040 }
@@ -2018,6 +2069,20 @@ function diffHydratedStyles(
2069 return;
2070 }
2071
2072 + if (
2073 + // Trailing semi-colon means this was regenerated.
2074 + normalizedServerValue[normalizedServerValue.length - 1] === ';' &&
2075 + // TODO: Should we just ignore any style if the style as been manipulated?
2076 + hasViewTransition((domElement: any))
2077 + ) {
2078 + // If this had a view transition we might have applied a view transition
2079 + // name/class and removed it. If that happens, the style attribute gets
2080 + // regenerated from the style object. This means we've lost the format
2081 + // that we sent from the server and is unable to diff it. We just treat
2082 + // it as passing even if it should be a mismatch in this edge case.
2083 + return;
2084 + }
2085 +
2086 // Otherwise, we create the object from the DOM for the diff view.
2087 serverDifferences.style = getStylesObjectFromElement(domElement);
2088 }