angulartypescriptangular-materialangular-lifecycle-hookszonejs

Angular 18: How to Ensure ngAfterViewInit Fires without zone.js in OnPush Components


Body:

I've recently removed zone.js from my Angular 18 project to optimize performance and am now facing an issue with the ngAfterViewInit lifecycle hook not firing consistently in a component with ChangeDetectionStrategy.OnPush. My current workaround involves using ApplicationRef.tick(), but I'm looking for a more appropriate solution that aligns with the reactive paradigm and does not rely on manual change detection triggers.

Context:

admin.component.ts:


@Component({
  selector: 'app-admin',
  templateUrl: './admin.component.html',
  styleUrls: ['./admin.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export default class AdminComponent implements OnDestroy, AfterViewInit {
  sidenav = viewChild.required<MatSidenav>(MatSidenav);

  private destroy$ = new Subject<void>();
  private observer = inject(BreakpointObserver);
  private router = inject(Router);
  private accountService = inject(AccountService);
  private cdr = inject(ChangeDetectorRef);
  private appRef = inject(ApplicationRef);
  constructor() {
    this.router.events.pipe(
      filter(e => e instanceof NavigationEnd), takeUntil(this.destroy$)
    ).subscribe(() => {
      this.appRef.tick();
    });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  ngAfterViewInit() {
    this.observer
      .observe(['(max-width: 800px)'])
      .pipe(takeUntil(this.destroy$), delay(1))
      .subscribe((res: { matches: boolean }) => {
        this.setSidenav(res.matches);
        this.cdr.detectChanges();
      });

    this.router.events
      .pipe(
        takeUntil(this.destroy$),
        filter((e) => e instanceof NavigationEnd)
      )
      .subscribe(() => {
        if (this.sidenav().mode === 'over') {
          this.setSidenav(false);
        }
      });
  }

  logout() {
    this.accountService.logout();
  }

  private setSidenav(matches: boolean) {
    if (matches) {
      this.sidenav().mode = 'over';
      this.sidenav().close();
    } else {
      this.sidenav().mode = 'side';
      this.sidenav().open();
    }
  }
}

admin.component.html:

<mat-toolbar color="primary" class="mat-elevation-z8">
  <button mat-icon-button *ngIf="sidenav.mode === 'over'" (click)="sidenav.toggle()">
    <mat-icon *ngIf="!sidenav.opened">menu</mat-icon>
    <mat-icon *ngIf="sidenav.opened">close</mat-icon>
  </button>
  <span class="admin-panel-title">Admin Panel</span>
</mat-toolbar>

<mat-sidenav-container>
  <mat-sidenav #sidenav="matSidenav" class="mat-elevation-z8">
    <div class="logo-container">
      <img routerLink="/" alt="logo" class="avatar mat-elevation-z8 logo-admin" src="../../../../assets/img/logo-s.png" />
    </div>
    <mat-divider></mat-divider>

    <ul class="nav-list">
      <li class="nav-item">
        <a routerLink="/admin" routerLinkActive="active" [routerLinkActiveOptions]="{exact:true}">
          <mat-icon class="nav-icon">home</mat-icon>
          <span>Dashboard</span>
        </a>
      </li>
      <li class="nav-item">
        <a routerLink="/admin/products" routerLinkActive="active">
          <mat-icon class="nav-icon">library_books</mat-icon>
          <span>Products</span>
        </a>
      </li>
      <li class="nav-item">
        <a routerLink="/admin/brands" routerLinkActive="active">
          <mat-icon class="nav-icon">branding_watermark</mat-icon>
          <span>Brands</span>
        </a>
      </li>
      <li class="nav-item">
        <a routerLink="/admin/product-types" routerLinkActive="active">
          <mat-icon class="nav-icon">branding_watermark</mat-icon>
          <span>Types</span>
        </a>
      </li>
      <li class="nav-item">
        <a routerLink="/admin/users" routerLinkActive="active">
          <mat-icon class="nav-icon">supervisor_account</mat-icon>
          <span>Users</span>
        </a>
      </li>
      <li class="nav-item">
        <a (click)="logout()" class="nav-item-logout" routerLinkActive="active">
          <i class="fa fa-sign-out fa-2x nav-icon-logout"></i>
          <span>Logout</span>
        </a>
      </li>
    </ul>

    <mat-divider></mat-divider>
  </mat-sidenav>
  <mat-sidenav-content>
    <div class="content mat-elevation-z8">
      <router-outlet></router-outlet>
    </div>
  </mat-sidenav-content>
</mat-sidenav-container>

The ngAfterViewInit() in my AdminComponent only logs when I explicitly call this.appRef.tick(). However, I would prefer not to use ApplicationRef for this purpose and am looking for a solution that allows Angular to handle updates more naturally without reverting to zone.js.

Problem:

Question: How can I ensure that lifecycle hooks like ngAfterViewInit are triggered appropriately in an Angular application with ChangeDetectionStrategy.OnPush when zone.js is removed? Are there any recommended practices or patterns for managing change detection manually in such cases?

What I've Tried:

  1. Using ApplicationRef.tick() to manually trigger change detection.
  2. Subscribing to router events and calling ChangeDetectorRef.detectChanges() within subscriptions.
  3. Using NgZone.run() for executing code that updates the view.

None of these methods have seamlessly integrated into the reactive architecture I aim for. I'm looking for a more integrated or Angular-recommended approach that enhances performance without compromising reactivity and maintainability.


Solution

  • I found the solution to my issue. The problem was related to running my Angular application without zone.js and using Angular Material components, specifically MatSidenav.

    Solution

    To resolve this, I needed to use the provideExperimentalZonelessChangeDetection() function in the providers array of my AppModule. This setup ensures that Angular can handle change detection properly in a zoneless configuration.

    AppModule:

    app.module.ts

    import { NgModule, provideExperimentalZonelessChangeDetection } from 
    
    @NgModule({
      declarations: [
        AppComponent,
      ],
      bootstrap: [AppComponent],
      imports: [
    
      ],
      providers: [
        provideExperimentalZonelessChangeDetection(),  // Add this line
      ]
    })
    export class AppModule { }
    

    Explanation

    After making this change, my application works perfectly without zone.js.