Skip to content

fix(core): don't overwrite static attributes of hydrated elements - #71200

Open
aminesbdev wants to merge 1 commit into
angular:mainfrom
aminesbdev:fix/core-hydration-static-attributes
Open

aminesbdev wants to merge 1 commit into
angular:mainfrom
aminesbdev:fix/core-hydration-static-attributes

Conversation

@aminesbdev

Copy link
Copy Markdown
Contributor

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • 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?

When an element has a static class or style attribute in its template, hydration replaces the server-rendered value with the static one. Anything added during SSR through Renderer2 (addClass, setStyle) is lost until the code that added it runs again on the client.

<a class="nav-link" routerLink="/about" routerLinkActive="active">About</a>

The server renders class="nav-link active". After hydration the link only has class="nav-link", and active comes back at the first NavigationEnd, so the link flashes. Without the static class attribute, tNode.classes is null and the class survives.

elementLikeStartShared skips appendChild when wasLastNodeCreated() is false, but calls setupStaticAttributes either way. writeDirectClass and writeDirectStyle then call setAttribute, which assumes a new, empty element.

Issue Number: N/A

What is the new behavior?

setupStaticAttributes only runs when the element was just created. Hydrated elements keep what the server rendered. Elements created on the client (no hydration, ngSkipHydration, client-only views) go through the same path as before.

I added a test in full_app_hydration_spec.ts: a directive adds a class and a style with Renderer2 on the server only, on an element with a static class and style. It fails without the change.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

This came up in #70828, which worked around the flash inside RouterLinkActive. I checked an app with a RouterLinkActive link and a lazy route that keeps the initial navigation pending: with this change, the server-rendered active class stays on the link through hydration until NavigationEnd, without any change to the directive.

`elementLikeStartShared` called `setupStaticAttributes` even when the element was
claimed from the server-rendered DOM instead of being created. That function writes
the `class` and `style` attributes directly, so on an element with a static `class` or
`style` it dropped anything added during SSR, for example a class added with
`Renderer2.addClass` by `RouterLinkActive`. The class came back only once the directive
ran again on the client, which caused a visible flash.

Static attributes are now only set up for elements that were just created, like
`appendChild` right below. Hydrated elements keep what the server rendered.

@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.

Thanks for looking into this as a follow-up to #70828!

While skipping setupStaticAttributes on hydrated elements looks appealing on the surface, doing so in elementLikeStartShared introduces several @angular/core regressions, changes the hydration baseline contract in undesirable ways, and still leaves RouterLinkActive (#70828) broken in key scenarios:

  1. Breaks static host animation triggers (@trigger) and @.disabled during hydration: setupStaticAttributes -> setUpAttributes doesn't just write DOM attributes — it dispatches @-prefixed static host entries (isAnimationProp) to renderer.setProperty(...), which is not serialized in SSR HTML and is required on the client to register triggers / disable animations in AnimationRenderer / AsyncAnimationRenderer.
  2. ngSkipHydration host & root component inconsistencies: wasLastNodeCreated() is false for a <my-comp ngSkipHydration> host element itself (because enterSkipHydrationBlock runs after lastNodeWasCreated), while ComponentFactory.create still calls setupStaticAttributes unconditionally on hydrated root hosts.
  3. Turns server-only imperative DOM mutations into permanent untracked client state (and drops client static hostAttrs / attrs whenever SSR/prerendered HTML differs).
  4. Does not actually solve RouterLinkActive (#70828): CSR and client-created views still flash; rla.isActive (false during hydration) becomes out of sync with the .active DOM (causing NG0500 hydration mismatches if templates branch on #rla="routerLinkActive"); and SSG/prerendered shells can keep stale .active / aria-current permanently when initial navigation cancels/errors or when [routerLinkActiveOptions]="null".

Left detailed inline comments on both files below.

Comment on lines +601 to +603
if (wasLastNodeCreated()) {
setupStaticAttributes(lView[RENDERER], native as RElement, 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.

Guarding setupStaticAttributes with wasLastNodeCreated() here introduces three issues in @angular/core:

1. Breaks static host animation triggers (@trigger) and @.disabled on hydrated elements

setupStaticAttributes -> setUpAttributes (packages/core/src/render3/util/attrs_utils.ts) does not only call renderer.setAttribute: when isAnimationProp(attrName) is true (@ prefix), it calls renderer.setProperty(native, attrName, attrVal).

  • Static host entries like host: {'@myTrigger': '', '@.disabled': 'true'} are compiled by parseHostBindings into hostAttrs -> tNode.mergedAttrs (rather than update-pass bindings).
  • During SSR, setProperty sets an in-memory property / hits NoopAnimationEngine — nothing is serialized into the HTML.
  • On the client, AnimationRenderer.setProperty (and AsyncAnimationRenderer.setProperty's replay queue) relies on that call to register the trigger via engine.process(...) or invoke disableAnimations(el, value) (and DefaultDomRenderer2.setProperty relies on it to throw NG05105 in dev mode when animation providers are missing). Skipping setupStaticAttributes silently drops static host @trigger registration and @.disabled on hydrated elements.

2. Also skips setupStaticAttributes on <my-comp ngSkipHydration> host elements

In locateOrCreateElementNodeImpl (packages/core/src/render3/instructions/element.ts):

  1. const isNodeCreationMode = !canHydrateNode(lView, tNode); and lastNodeWasCreated(isNodeCreationMode); run first, while isInSkipHydrationBlock() is still false — setting wasLastNodeCreated() to false.
  2. enterSkipHydrationBlock(tNode) and clearElementContents(native) only run afterwards on that element.

So contrary to the PR description, the ngSkipHydration host element itself has wasLastNodeCreated() === false and now skips setupStaticAttributes despite opting out of hydration.

3. Inconsistent with hydrated root component hosts

ComponentFactory.create (packages/core/src/render3/component_ref.ts) calls setupStaticAttributes(hostRenderer, hostElement, hostTNode) unconditionally even when retrieveHydrationInfo enables hydration on the root view, so root component hosts still overwrite SSR attributes/classes/styles while child elements do not.

Comment on lines +493 to +498
if (isPlatformServer(inject(PLATFORM_ID))) {
const renderer = inject(Renderer2);
const element = inject(ElementRef).nativeElement;
renderer.addClass(element, 'active');
renderer.setStyle(element, 'color', 'red');
}

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.

This test highlights the hydration-contract problem with this approach, as well as why preserving raw SSR attributes doesn't actually resolve RouterLinkActive (#70828):

1. Codifies permanent SSR/CSR DOM divergence

Running renderer.addClass / renderer.setStyle exclusively inside if (isPlatformServer(...)) and asserting that active and color: red remain on the client after hydration stabilizes turns a server/client mismatch into permanent, untracked DOM state that Angular has no awareness of. Conversely, skipping setupStaticAttributes means any client-side static hostAttrs / attrs not already present in the server HTML (e.g., rolling-deploy version skew against CDN-cached SSR/SSG HTML, browser-conditional directives, or custom Renderer2 wrappers) are never applied.

2. Why RouterLinkActive (#70828) still breaks under this approach

RouterLinkActive loses its active class during hydration because RouterLinkActive itself waits until NavigationEnd (if (!this.links || !this.router.navigated) return;) to compute its state on the client:

  • CSR & client-rendered views (@if, @defer, ngSkipHydration): still flash inactive during initial navigation.
  • rla.isActive is out of sync with the DOM (and can trigger NG0500): During hydration, RouterLinkActive initializes _isActive = false, while the DOM keeps .active from SSR. Any template binding to #rla="routerLinkActive" — such as [class.bold]="rla.isActive" or @if (rla.isActive) { ... } — will either still flash or fail hydration with NG0500 (HYDRATION_NODE_MISMATCH) because SSR rendered rla.isActive === true while client hydration evaluates false.
  • Stale .active / aria-current stuck forever on SSG / prerendered shells:
    • If / is prerendered with Home active and the browser loads /admin where an initial guard cancels (NavigationCancel) or errors (NavigationError), router.navigated remains false and NavigationEnd never fires — leaving Home permanently .active with aria-current="page".
    • Even when NavigationEnd does succeed on /admin, if [routerLinkActiveOptions]="null" is set, RouterLinkActive.update() hits if (this.routerLinkActiveOptions === null && !this._isActive) return; and bails out early (since _isActive initialized to false on the client), never removing the SSR .active class or aria-current attribute.

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.

2 participants