Search code examples
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:

  • Angular version: 18.0.6
  • I've successfully removed zone.js, but this has led to lifecycle hooks not being triggered as expected.
  • The application structure involves an admin component that uses Angular Material's MatSidenav, controlled via reactive signals from @angular/core.

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:

  • The ngAfterViewInit() method is not triggering as expected without manual intervention via ApplicationRef.tick().
  • This issue occurs only after removing zone.js, indicating a challenge with Angular's change detection mechanism in a zone-less environment.

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

    • By adding provideExperimentalZonelessChangeDetection() to the providers array, Angular is configured to handle change detection properly without zone.js.
    • This is especially important when using Angular Material components like MatSidenav, which rely on change detection to function correctly.

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