Skip to content

bug: StackController transition crashes when router outlet is destroyed mid-transition #31296

Description

Prerequisites

Ionic Framework Version

v8.x (reproduced on 8.8.13; the unguarded code is still present on main and in 8.8.16-nightly.20260724)

Current Behavior

StackController.destroy() sets this.containerEl = undefined! (stack-controller.ts#L240). A transition that is still queued (via the wait() serialization) can resolve after the ion-router-outlet was destroyed — e.g. an interrupted, re-entrant, or cancelled navigation during fast back-navigation. When it does, transition() dereferences the now-undefined element:

const containerEl = this.containerEl;            // undefined after destroy()
if ((containerEl as any).commit) {               // TypeError

which throws:

TypeError: undefined is not an object (evaluating 'containerEl.commit')
    at transition

We see this at meaningful scale in a production Ionic Angular + Capacitor app (Datadog RUM): several hundred crash events per week from a single app build, concentrated on fast back-navigation between nested router outlets (message-thread → channel list). The minified symbol varies per build (d.commit, Fn.commit, Tt.commit, Vn.commit), but all decode to this same line.

Expected Behavior

A transition that resolves after its outlet was destroyed should be a no-op (resolve false, the method's existing fallback) rather than throwing an unhandled TypeError.

Steps to Reproduce

The bug is a race between a queued transition and outlet destruction, so it is timing-dependent rather than deterministically reproducible in a minimal app:

  1. Navigate forward into a page hosted by a nested ion-router-outlet.
  2. Trigger back-navigation twice in quick succession (double-tap back / rapid NavController.back() calls) so that a second transition queues behind the first via StackController.wait().
  3. If the first navigation destroys the nested outlet (route leaves the outlet's subtree), the queued transition resolves against the destroyed stack controller and throws.

The code path makes the defect visible without a live repro: destroy() intentionally nulls containerEl (with a lint-suppressed non-null assertion), and transition() has no corresponding guard.

Code Reproduction URL

https://github.com/ionic-team/ionic-framework/blob/main/packages/angular/common/src/directives/navigation/stack-controller.ts#L240 (destroy) and https://github.com/ionic-team/ionic-framework/blob/main/packages/angular/common/src/directives/navigation/stack-controller.ts#L278 (unguarded dereference). A deterministic minimal repro is impractical for this race; production stack traces and the code path above demonstrate it. Happy to provide additional RUM data if useful.

Ionic Info

Ionic:

   Ionic CLI                     : 7.2.1
   Ionic Framework               : @ionic/angular 8.8.13
   @angular-devkit/build-angular : 21.2.19
   @angular-devkit/schematics    : 21.2.19
   @angular/cli                  : 21.2.19
   @ionic/angular-toolkit        : 12.3.0

Capacitor:

   Capacitor CLI      : 8.4.1
   @capacitor/android : 8.4.1
   @capacitor/core    : 8.4.1
   @capacitor/ios     : 8.4.1

System:

   NodeJS : v24.18.0
   npm    : 11.16.0
   OS     : Windows 10

Additional Information

We have been shipping the one-line guard (if ((containerEl as any)?.commit)) via patch-package in production; the crash class disappears in patched builds with no observed side effects. PR with the fix incoming.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions