Skip to content

fix(core): don't reload iframes and media during hydration - #71874

Open
jsaguet wants to merge 1 commit into
angular:mainfrom
jsaguet:fix/hydration-skip-unchanged-dom-writes
Open

jsaguet wants to merge 1 commit into
angular:mainfrom
jsaguet:fix/hydration-skip-unchanged-dom-writes

Conversation

@jsaguet

@jsaguet jsaguet commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

PR Checklist

PR Type

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

Fixes #52477

During hydration, Angular writes the src of <iframe>, <embed>, <video>, and <audio>, and the data of <object>, again with the value that the server rendered. The browser loads the resource again whenever one of these attributes is set: an iframe loads its document again and a playing video restarts.

What is the new behavior?

 on an element reused from the server, for each static attribute
+  if it's a resource URL and the DOM already has the value
+    skip it
   write it

 on the first update of a hydrated view, for each property or attribute binding
+  if it's a resource URL and the DOM already has the value
+    skip it
   write it

A resource URL is src on iframe, embed, video, or audio, or data on object (isResourceAttribute). The comparison runs after sanitization, on the string value. Every other attribute, class, style, and binding is written as before. #71971 tracks the cost of those writes.

Does this PR introduce a breaking change?

  • Yes
  • No

I've left this open for you to decide. The final attributes don't change, but a few handlers that ran only because of the repeated write no longer run. See Merge Danger.

Other information

Evidence

Chrome 156, from the reproduction:

Before After
<video> with src, [src], or [attr.src] that was playing at 1.5 s Back at 0.34 s Still playing
Control <video> whose src differs between server and browser Restarts Restarts
<iframe> with src or [src], documents loaded 2 1

These specs fail without the source change and pass with it:

hydration_dom_writes_spec.ts   should not write static resource URLs that the server rendered
hydration_dom_writes_spec.ts   should not write resource URLs that the server rendered

The other new specs pass both ways. They cover:

  • other static attributes, class, and style, which are still written;
  • values that differ from the server or are missing from the DOM;
  • later updates;
  • nodes created on the client;
  • animation properties;
  • Trusted Types values;
  • other bindings;
  • replay of load on an iframe.

Merge Danger

Door: two-way. No API or serialized format changes.

Blast Radius: hydrated apps with iframe, embed, video, audio, or object elements.

These handlers no longer run for an event that fired before hydration, because nothing fires it again:

  • Media events such as (loadedmetadata), (canplay), and (ended) on a video that was ready before hydration. Today they run because the video restarts. Event replay doesn't deliver them: media events don't bubble, and replay listens for them in the bubble phase or not at all. Code can check readyState in afterNextRender instead.
  • Listeners for load on an iframe that are added imperatively (fromEvent, renderer.listen), and postMessage handshakes that rely on the second load.
  • (load) on an iframe in an app with withNoIncrementalHydration() and no withEventReplay().

With event replay on, which is the default, (load) on an iframe runs once instead of twice.

An iframe that navigated before hydration is no longer sent back to its src. The same goes for server HTML whose sandbox or allow differs but whose src matches: the new policy only applies at the next navigation.

Not covered:

  • Static attributes inside ICU expressions, which i18n sets separately.
  • A customized built-in, such as <video is="my-video">, whose own src setter isn't called when the write is skipped. A hasAttribute('is') check would cover it, if you want one.

@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Nov 8, 2026
@google-cla

google-cla Bot commented Nov 8, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@ngbot ngbot Bot added this to the Backlog milestone Nov 8, 2026
@jsaguet
jsaguet force-pushed the fix/hydration-skip-unchanged-dom-writes branch from 22b9b30 to 53ce2a0 Compare November 8, 2026 12:39

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Addressing redundant DOM writes during hydration in @angular/core is the right architectural direction (and addresses the root cause behind issues like #71872 and #71873). However, there are a few correctness and design issues with how setupStaticAttributes and isDomPropertyUnchanged currently check the DOM—see inline comments below.

export function setupStaticAttributes(
renderer: Renderer,
element: RElement,
tNode: TNode,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Running setupStaticAttributes with skipIfUnchanged during hydration (creation mode) compares the SSR DOM against the static tNode attributes/classes/styles and overwrites them whenever they differ. Because creation mode runs before directive ngOnInit and update-mode bindings ([class.*], [style.*], [attr.*]), any attribute, class, or style that was legitimately augmented on the server by a directive or binding will differ from the static tNode value and get clobbered during hydration:

  1. Combined static + dynamic class / style: For <div class="static" [class.active]="true">, the SSR HTML has class="static active". In creation mode, element.getAttribute('class') !== classes ('static active' !== 'static') evaluates to true, so writeDirectClass clobbers the attribute back to 'static', only for the update pass to re-add 'active' immediately after—resulting in two unnecessary DOM mutations during hydration.
  2. Directive-modified attributes (e.g. NgOptimizedImage in NgOptimizedImage makes the browser select each image source again during hydration #71873): For <img ngSrc="hero.jpg" width="400" height="300" sizes="50vw">, SSR renders sizes="auto, 50vw". Here native.getAttribute('sizes') !== '50vw' is true, so setupStaticAttributes overwrites sizes back to '50vw', and NgOptimizedImage.ngOnInit() then writes 'auto, 50vw' again.

When hydrating an existing element (!wasLastNodeCreated()), the server has already serialized its static attributes, classes, and styles. Have we considered skipping setupStaticAttributes altogether when !wasLastNodeCreated() (aside from isAnimationProp properties which aren't serialized as HTML attributes)? That would avoid all the getAttribute reads on static attributes and prevent clobbering directive/binding state from SSR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those writes still happen, as they do today: static active differs from the template, so it's replaced, and [class.active] adds the class back.

I'd keep the comparison rather than skip setupStaticAttributes altogether, because skipping is what #71200 does, and your review there lists what it breaks. With the comparison, a server-only class or a value from an older deployment is still corrected, animation properties are still set, and an ngSkipHydration host is still written when it differs. Root hosts aren't touched.

The cost is that the sizes case stays as it is. #71873 tracks that one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Thanks for updating the PR and scoping down the dynamic property/attribute checks—narrowing isFirstWriteOfRenderedResource to video, audio, iframe, embed, and object[data] makes the property-binding side much safer and avoids the pitfalls with generic DOM property reflection and custom elements.

Regarding setupStaticAttributes:

Fair point about the subtle regressions identified in #71200 (such as root hosts, animation properties, and ngSkipHydration).

However, comparing element.getAttribute('class') !== classes and element.getAttribute('style') !== styles in creation mode has a fundamental limitation: it only checks static classes/styles against the SSR DOM, but the SSR DOM legitimately contains classes and styles added by update bindings ([class.*], [style.*]) and directives. On any element combining static classes with dynamic bindings (e.g. <div class="card" [class.active]="isActive">), getAttribute('class') is 'card active', which doesn't match 'card', so it still overwrites the class back to 'card' in creation mode, only for [class.active] to re-add 'active' right after.

Rather than trying to partially patch class and style reconciliation here (which is a much broader problem with styling pipelines and directives like in #71873):

What do you think about narrowing this PR specifically to the media/resource loading bug (#71872)?

  1. Keep your narrowed dynamic src / data check in shared.ts (isFirstWriteOfRenderedResource).
  2. In setupStaticAttributes / setUpAttributes, guard only static src / data for these media elements (video, audio, iframe, embed, object), leaving class and style untouched.

That gives a very clean separation of concerns:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I narrowed the PR as you suggested and squashed it into one commit:

  • Static values: only src on iframe, embed, video, and audio, and data on object, are compared. class and style are written as before.
  • Bindings: the same check, through one shared isResourceAttribute.

I opened #71971 for the cost of the other writes, with the measurements and the class/style problem you described.

}

/** Properties whose getter returns the resolved URL rather than the value that was set. */
const URL_PROPERTIES = /^(src|href|poster|data|action|formAction|cite)$/;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: A few concerns with isFirstPassOfHydratedView and isDomPropertyUnchanged:

  1. View-scoped vs. node-scoped check: isFirstPassOfHydratedView(lView) is true for the entire LView, including DOM nodes inside that view that were created fresh on the client (e.g. inside an ngSkipHydration block or unhydrated fallback nodes). On a newly created element, bindings like [title]="''" or [customProp]="undefined" will see (element as any)[propName] === value as true and skip calling renderer.setProperty.
  2. Customized built-in elements: element.tagName.includes('-') only matches autonomous custom elements, missing customized built-in elements (<button is="my-button">) whose property setters would be skipped.
  3. Type coercion & URL_PROPERTIES: Non-string values bound to string DOM properties (e.g. <input [value]="123">) fail strict equality ('123' === 123 is false), and URL_PROPERTIES doesn't account for cases like SVGElement.href (SVGAnimatedString).
  4. Renderer2 boundary: Accessing element.getAttribute / element.tagName / element[propName] directly in core bypasses Renderer2.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the generic property comparison. The second commit now covers only src on iframe, embed, video, and audio, and data on object.

  1. A node created on the client doesn't have the attribute, so it's still written. There's a spec for this.
  2. A customized built-in such as <video is="my-video"> with its own src setter would still be skipped. It's a narrow case, so I've left it out to keep the check small. A server-rendered customized built-in always has the is attribute, so an extra hasAttribute('is') check would cover it. Would you like me to add that?
  3. The comparison is between the sanitized value as a string and the attribute, so it doesn't read a property. SVG isn't in the list.
  4. It still reads getAttribute directly, for those five elements only. Hydration already does the same in hydration/utils.ts.

@Component({
selector: 'app',
template: `<div class="static" [class]="classes"></div>`,
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Note that this test (<div class="static" [class]="classes"></div>) only removes server-only because the presence of the static class="static" attribute causes setupStaticAttributes to overwrite class="static server-only" back to class="static" during creation mode. Without a static class attribute (<div [class]="classes"></div>), tNode.classes is null and server-only is not removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the behavior the spec is meant to keep. I renamed it to say that it applies to an element with a static class. Without a static class, the server's class stays, as it does today.

During hydration, Angular wrote the `src` of `<iframe>`, `<embed>`,
`<video>`, and `<audio>` elements, and the `data` of `<object>`
elements, again with the value that the server had rendered. The
browser loads the resource again whenever one of these attributes is
set, even to the same value: an iframe loads its document again and a
playing video restarts.

On elements reused from the server, core now leaves these attributes
alone when they already have the value. This applies to static
attributes outside ICU expressions, and to the first update of property
and attribute bindings. A value that differs is still written, and
later updates aren't affected.

Fixes angular#52477
@jsaguet
jsaguet force-pushed the fix/hydration-skip-unchanged-dom-writes branch from 9a480b2 to a5a00a3 Compare November 11, 2026 19:57
@jsaguet jsaguet changed the title fix(core): don't re-write unchanged values on hydrated DOM nodes fix(core): don't reload iframes and media during hydration Nov 11, 2026
@jsaguet
jsaguet marked this pull request as ready for review November 11, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Issues related to the framework runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static attributes are not hydrated, causing refetching of external resources

2 participants