|

Elegant Navigation Guards in Angular - Part 2: Custom Modals and Testable Providers

In Part 1 of this series, we built a reusable functional guard to block route navigation whenever a form component was in a “dirty” state. We relied on the native browser confirm() dialogue.

While functional, native confirm dialogs cannot be styled to match modern design systems and make automated testing difficult.

In this second part, we will solve these issues by decoupling our confirmation logic using Dependency Injection (DI) Providers and modern Angular Standalone Provider Functions.


Step 1: Introducing the Injection Token

Instead of referencing confirm() in the guard, we declare an abstraction for the warning dialog: a NavigationConfirm interface and an InjectionToken.

Let’s update pending-changes.ts with these definitions:

import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders, Type } from '@angular/core';
import { Observable } from 'rxjs';

export interface PendingChanges {
  isDirty(): boolean | Observable<boolean> | Promise<boolean>;
}

// 1. The contract for showing the confirmation dialog
export interface NavigationConfirm {
  confirm(): boolean | Observable<boolean> | Promise<boolean>;
}

// 2. The Injection Token with a default browser confirm fallback
export const NAVIGATION_CONFIRM = new InjectionToken<NavigationConfirm>(
  'NAVIGATION_CONFIRM',
  {
    providedIn: 'root',
    factory: () => ({
      confirm: () => confirm('You have unsaved changes. Do you really want to leave?')
    })
  }
);

Step 2: Writing the Custom Provider Helper Function

Modern Angular utilizes dedicated provider helper functions (like provideRouter() or provideHttpClient()) to register configuration.

We can write our own standalone provider function using Angular’s makeEnvironmentProviders. Add this helper to the bottom of pending-changes.ts:

// 3. Modern provider helper function using makeEnvironmentProviders
export function provideNavigationConfirm(
  confirmService: Type<NavigationConfirm>
): EnvironmentProviders {
  return makeEnvironmentProviders([
    {
      provide: NAVIGATION_CONFIRM,
      useClass: confirmService
    }
  ]);
}

Using makeEnvironmentProviders restricts this provider to application/route injectors, preventing it from being accidentally declared in a component-scoped array where it could cause hierarchy issues.


Step 3: Refactoring the Route Guard to Use DI

Now we update our functional guard to inject the NAVIGATION_CONFIRM token:

import { inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';

export const pendingChangesGuard: CanDeactivateFn<PendingChanges> = (component) => {
  if (!component || !component.isDirty()) {
    return true;
  }

  // Inject the abstract confirmation service
  const confirmService = inject(NAVIGATION_CONFIRM);

  // Return the result of the abstract confirm call
  return confirmService.confirm();
};

Step 4: Overriding the Native Confirm with a UI Modal

Now, let’s implement a clean dialog service using Angular Material’s MatDialog that returns an Observable<boolean> when the user chooses an option:

import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { NavigationConfirm } from './pending-changes';
import { ConfirmDialogComponent } from './confirm-dialog.component';
import { Observable } from 'rxjs';

@Injectable()
export class CustomNavigationConfirmService implements NavigationConfirm {
  constructor(private dialog: MatDialog) {}

  confirm(): Observable<boolean> {
    const dialogRef = this.dialog.open(ConfirmDialogComponent, {
      width: '400px',
      disableClose: true
    });

    return dialogRef.afterClosed();
  }
}

Now, hook up the custom implementation in app.config.ts using our standalone helper:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideNavigationConfirm } from './pending-changes';
import { CustomNavigationConfirmService } from './custom-navigation-confirm.service';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    // Swap out the default browser confirm with our Material Dialog service
    provideNavigationConfirm(CustomNavigationConfirmService)
  ]
};

Step 5: Painless Unit Testing

One of the biggest wins of decoupling confirmation dialogs with providers is unit testing. We no longer have to mock window objects or block the headless test runner.

In our unit tests, we can simply provide a mock implementation that auto-approves navigation:

import { TestBed } from '@angular/core/testing';
import { NAVIGATION_CONFIRM } from './pending-changes';
import { pendingChangesGuard } from './pending-changes';

describe('pendingChangesGuard', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: NAVIGATION_CONFIRM,
          useValue: {
            confirm: () => true // Auto-allow during testing
          }
        }
      ]
    });
  });

  it('should allow navigation when mocked', () => {
    const mockComponent = { isDirty: () => true };
    const canLeave = TestBed.runInInjectionContext(() => 
      pendingChangesGuard(mockComponent)
    );
    expect(canLeave).toBe(true);
  });
});

Summary

By combining Angular’s functional guards with custom providers, we successfully build a clean routing architecture. The guard remains completely isolated from the UI presentation layer, allowing developers to switch between browser dialogues, custom design system modals, or mock unit-test configurations seamlessly.