Skip to content

Hydration: detect common mismatch patterns and produce a better error message #56392

Open

Description

@AndrewKushnir

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

During hydration, Angular relies on the application structure to be the same between the server and the client. There are a few cases where if you have a component template that does not have valid HTML structure, this could result in a DOM mismatch error during hydration, see https://angular.dev/guide/hydration#valid-html-structure.

Here is an example that we've came across on ADEV: #56390.

Proposed solution

The proposal is to auto-detect those situations when hydration mismatch happens and output additional information into the console, to make it easier to find the origin of the problem and resolve it.

Activity

  1. added this to the needsTriage milestone on Jun 11, 2024
  2. JeanMeche commented on Jun 11, 2024

    @JeanMeche
    Member
  3. yjaaidi commented on Jun 26, 2024

    @yjaaidi
    Contributor

    As #56712 was merged here, I just wanted to note that it is important to detect these mismatches in production too as 3rd party scripts might interfere (proxies, browser extensions, ...).
    As developers have generally no control over these (e.g. user browser extensions), hydration should recover when mismatches happen in production.

  4. robertIsaac commented on Jul 21, 2024

    @robertIsaac
    Contributor

    the error message is very ambiguous
    as in #57063 it took me over 10 hours of debugging to be able to reproduce the problem, just to know it's because p can't have nested dev
    I didn't understand at all what was the problem and my only thought it must be internal issue

  5. added
    canonicalThis issue represents a canonical design issue in Angular.
    on Dec 18, 2024
  6. added
    open for contributionsAn issue that is suitable for a community contributor (based on its complexity/scope).
    on Apr 8, 2026
  7. added a commit that references this issue on May 3, 2026
    43b310f
  8. VenkatKwest commented on Jun 16, 2026

    @VenkatKwest

    Hi @alan-agius4,

    #56712 (now locked) raised this concern: "Angular could bind data or apply event listeners to the wrong element during hydration" when third-party interference adds/removes DOM nodes before hydration runs, and a comment here noted it's "important to detect these mismatches in production too as 3rd party scripts might interfere."

    I went looking for the current state of this on Angular 22.0.1 (latest stable as of this comment, also the version that fixed GHSA-rgjc-h3x7-9mwg) and can confirm the exact misbind described in #56712 still reproduces, with a concrete repro and click-level evidence rather than the general description.

    Setup: a plain Angular 22 SSR app (ng new --ssr, provideClientHydration(), nothing custom), a component with five static sibling rows, each with its own (click) handler:

    <ul id="user-list">
      <li class="user-row">
        <span class="user-name">Alice</span>
        <button class="del-btn" (click)="onDelete(1)">Delete</button>
      </li>
      <li class="user-row">
        <span class="user-name">Bob</span>
        <button class="del-btn" (click)="onDelete(2)">Delete</button>
      </li>
      <li class="user-row">
        <span class="user-name">Carol</span>
        <button class="del-btn" (click)="onDelete(3)">Delete</button>
      </li>
      <li class="user-row">
        <span class="user-name">Dave</span>
        <button class="del-btn" (click)="onDelete(4)">Delete</button>
      </li>
      <li class="user-row">
        <span class="user-name">Eve</span>
        <button class="del-btn" (click)="onDelete(5)">Delete</button>
      </li>
    </ul>
    export class App {
      protected readonly log = signal('none');
      onDelete(id: number): void {
        this.log.set(`deleted-user-id:${id}`);
      }
    }

    No *ngFor, no *ngIf, no <ng-container> - just plain sibling elements, which is what most of the "interference" reports above describe (browser extensions, ad widgets, CDN/edge rewrites all insert plain elements like this, not structural-directive content).

    To simulate the interference without needing a real browser extension, I added an Express middleware in server.ts that rewrites the SSR response before sending it, inserting one extra same-shape <li> as the first child of the list:

    const html = await response.text();
    const injected = html.replace(
      '<ul id="user-list">',
      '<ul id="user-list"><li class="user-row" data-sponsored="true">' +
        '<span class="user-name">Sponsored</span>' +
        '<button class="del-btn">Visit ad</button></li>',
    );

    This stands in for any of: a browser extension, an ad network's in-feed unit, a CDN/edge HTML rewrite, a CMS-injected banner, or a race with the app's own async-appended content. None of these require DOM clobbering, an id collision, or a sanitizer bypass - the injected element only needs to be a plausible same-tag, same-child-shape sibling, which ordinary ads/widgets are anyway.

    The resulting HTML sent to the browser:

    <ul id="user-list">
      <li class="user-row" data-sponsored="true"><span class="user-name">Sponsored</span><button class="del-btn">Visit ad</button></li>
      <li class="user-row"><span class="user-name">Alice</span><button class="del-btn">Delete</button></li>
      <li class="user-row"><span class="user-name">Bob</span><button class="del-btn">Delete</button></li>
      <li class="user-row"><span class="user-name">Carol</span><button class="del-btn">Delete</button></li>
      <li class="user-row"><span class="user-name">Dave</span><button class="del-btn">Delete</button></li>
      <li class="user-row"><span class="user-name">Eve</span><button class="del-btn">Delete</button></li>
    </ul>

    I drove this with a real Chromium (Playwright), production build (ng build, not --configuration development), loading the page fresh and clicking the button inside whichever row's text matches a given name:

    import { chromium } from 'playwright';
    
    const names = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve'];
    const expectedIds = { Alice: 1, Bob: 2, Carol: 3, Dave: 4, Eve: 5 };
    const browser = await chromium.launch();
    
    for (const name of names) {
      const page = await browser.newPage();
      const errors = [];
      page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
      page.on('pageerror', (e) => errors.push(String(e)));
    
      await page.goto('http://localhost:4000/', { waitUntil: 'networkidle' });
      const row = page.locator('#user-list .user-row', { hasText: name });
      await row.locator('button.del-btn').click({ timeout: 5000 });
    
      const logText = (await page.locator('#action-log').textContent()).trim();
      console.log(`Clicked "${name}" -> log="${logText}" expected="deleted-user-id:${expectedIds[name]}"` +
        (errors.length ? ` | errors: ${errors.join(' /// ')}` : ' | no console errors'));
      await page.close();
    }
    await browser.close();

    Output (production build shown; development build produces the same result):

    Clicked "Alice" -> log="deleted-user-id:2" expected="deleted-user-id:1" | no console errors
    Clicked "Bob"   -> log="deleted-user-id:3" expected="deleted-user-id:2" | no console errors
    Clicked "Carol" -> log="deleted-user-id:4" expected="deleted-user-id:3" | no console errors
    Clicked "Dave"  -> log="deleted-user-id:5" expected="deleted-user-id:4" | no console errors
    Clicked "Eve"   -> log="none"              expected="deleted-user-id:5" | no console errors
    

    Every row's button fires the next row's handler. No NG0500, no warning, nothing in the console, in either a production or development build. A user who looks at the page and clicks "Delete" next to Carol's name deletes Dave instead, with no indication anything went wrong.

    For comparison, I also tried the same single-extra-sibling interference against a *ngFor-based version of the same list (<li *ngFor="let user of users()">). That case behaves differently: in a development build it throws a clear NG0500 ("expected a comment node but found <li>") pointing at the container anchor, consistent with the error-message work merged in #68165. In a production build it partially recovers - most rows end up correct because the row's displayed text and its (click) handler are bound from the same iteration context and move together, but the list visibly breaks (the last item gets duplicated, with the original instance lacking its event listener). So structural-directive content fails loud or fails visibly; the plain-sibling case fails completely silently, which seems like the more dangerous of the two and the one this issue's line "Angular could bind data or apply event listeners to the wrong element during hydration" is really describing.

    Repro is a stock ng new --ssr app plus the four small changes above (app.ts, app.html, server.ts, the Playwright script) - happy to push it to a public repo if that's useful, or trim it down further if it helps narrow down a fix. Tested on @angular/core 22.0.1 / @angular/cli 22.0.1, Node 25.6.0, Chromium 149 (Playwright 1.61.0), Windows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimecanonicalThis issue represents a canonical design issue in Angular.core: hydrationopen for contributionsAn issue that is suitable for a community contributor (based on its complexity/scope).

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      Sponsor
      SponsoredKunjungi sekarang
      Promo