Skip to content

Fragment teardown drops in-flight scenario records and the last log flush: the broadcast channel closes before the services #36

Open

Description

@fateeand

Summary

When an Angular app is destroyed while its page stays alive, most importantly a micro-frontend fragment being unmounted by its shell, the broadcast sink, the broadcast log provider and the shared channel are torn down before the scenario and logger services run their own cleanup. Whatever that cleanup sends is dropped.

In a fragment using provideUwtTelemetrySink('broadcast'):

  • A scenario still in flight is settled as abandoned by UwtScenarioTelemetryService.ngOnDestroy, but the record never reaches the shell.
  • The final log-flush, and any line logged by another service's ngOnDestroy, never reaches the shell either.

The shell therefore never learns that the user left the fragment mid-journey, which is the signal abandoned exists to carry. Log lines sent earlier are unaffected, because each is posted immediately.

This is not a regression. 0.4.0 behaves the same: the sink closed its own channel in its own ngOnDestroy, ahead of the services that inject it. It was found while reviewing #34 (the core extraction) and deliberately left out of that PR.

When it happens, and when it does not

Situation Affected?
Fragment unmounted by a shell, app destroyed (ApplicationRef.destroy(), EnvironmentInjector.destroy()), HMR, TestBed reset Yes
User closes the tab or navigates away No. ngOnDestroy never runs; the pagehide listener settles scenarios and flushes while the channel is still open
Standalone app with the RUM sink, app destroyed Same ordering problem, see "Scope"

Open question, to settle during manual verification below: if the fragment's window fires pagehide before Angular destroys the app, the abandoned record has already gone out and the real-world impact is smaller. That depends on how the shell unmounts the fragment.

Root cause

Angular registers an injectable's ngOnDestroy after its factory has run, that is after its dependencies, and R3Injector.destroy() calls the hooks in that order. A dependency is therefore always destroyed before the services that inject it. Checked in @angular/core 21.0.9 (fesm2022/_untracked-chunk.mjs, hydrate() and destroy()):

  1. destroy() sets _destroyed = true.
  2. It calls every ngOnDestroy, dependencies first.
  3. Only then does it run the DestroyRef.onDestroy callbacks.

Here, UwtScenarioTelemetryService and UwtLoggerService inject the sink and the log provider, so those and the shared UwtBroadcastConnectionRef are destroyed first. When the services' ngOnDestroy then runs:

  • UwtScenarioTelemetryService.ngOnDestroy() calls the core dispose(), which settles in-flight scenarios as abandoned and calls sink.flush(true).
  • UwtLoggerService.ngOnDestroy() calls the core dispose(), which calls logApi.flush().

Both post into a channel that is already closed, and the sink and provider have already disposed, which also gates them. The messages are dropped silently.

Why no test caught it

Every existing teardown test builds its realm with Injector.create(...) and calls ngOnDestroy() by hand (see uwt-broadcast.spec.ts, "should stop delivering once the fragment is destroyed"). None exercises the injector's real destruction order.

Reproduction (a test that fails today)

Use a real injector teardown: TestBed.resetTestingModule(), or createEnvironmentInjector(...) followed by .destroy(). Build the shell side as uwt-broadcast.spec.ts does: a RecordingSink behind a UwtTelemetryBroadcastHost, with UwtBroadcastChannelStub.install() standing in for BroadcastChannel.

it('forwards a scenario still in flight when the fragment is destroyed', async () => {
  // shell: recording sink + UwtTelemetryBroadcastHost, as in uwt-broadcast.spec.ts
  TestBed.configureTestingModule({
    providers: [
      provideUwtTelemetry({
        application: 'cart',
        environment: 'test',
        version: '1'
      }),
      provideUwtTelemetrySink('broadcast')
    ]
  });
  TestBed.inject(UwtScenarioTelemetryService).start({ name: 'checkout' }); // never settled

  TestBed.resetTestingModule(); // the real teardown order
  await UwtBroadcastChannelStub.settle();

  // today: nothing arrives. Expected: one scenario record, status 'abandoned'
  expect(shellSink.ofType(UWT_TELEMETRY_EVENT_TYPE.scenario)).toEqual([
    expect.objectContaining({ scenarioName: 'checkout', status: 'abandoned' })
  ]);
});

Proposed fix

Make the destinations dispose after every service's ngOnDestroy, using the DestroyRef callbacks Angular runs after them. Apply it in each Angular destination class: UwtBroadcastTelemetrySink, UwtBroadcastLogApiProvider and UwtBroadcastConnectionRef, plus UwtRumTelemetrySink (see "Scope").

private readonly destroyRef = inject(DestroyRef);

constructor() {
  super({ /* unchanged */ });
  // Runs after every service's ngOnDestroy, so their last messages still go out.
  this.destroyRef.onDestroy(() => this.dispose());
}

/** @inheritdoc */
ngOnDestroy(): void {
  // The injector calls this first and the callback above finishes the job;
  // a direct call (tests, a subclass) disposes at once, as before.
  if (!this.destroyRef.destroyed) {
    this.dispose();
  }
}

Why it has to look like this:

  • The callback must be registered in the constructor. Registering one inside ngOnDestroy throws, because _destroyed is already true by then.
  • DestroyRef.destroyed exists in Angular 21 (the supported range is 21 to 22) and is true while the injector tears down. That is what separates the injector's call from a manual one.
  • ngOnDestroy() must stay. It is part of the published API: the API snapshot lists it, and a user's subclass may call super.ngOnDestroy(). Keeping it also keeps every existing spec that calls it directly passing unchanged.
  • dispose() is already idempotent in the core classes, so a double call is harmless.
  • Keep the 3 lines inline per class. Don't add a shared helper: the /rum entry would need yet another private copy of it (see uwt-rum-internal.util.ts).

Resulting order on destroy: the services' ngOnDestroy (scenarios settled, logs flushed, all through a live sink and channel), then the sink and provider dispose and the connection closes.

Alternatives considered

  • Reorder creation: impossible. Angular always registers dependencies first.
  • Remove OnDestroy from these classes: a public API change, and it breaks subclasses that call super.ngOnDestroy().
  • Defer with queueMicrotask or setTimeout: non-deterministic, and leaks in tests.
  • Flush in ApplicationRef.onDestroy: only works when the app is destroyed through ApplicationRef, not for a bare environment injector.
  • Change the core: no. Disposal order is the caller's responsibility there (the core README says to dispose the engine first, then what you created for it).

Scope

In scope:

  • The three broadcast classes above.
  • UwtRumTelemetrySink: its ngOnDestroy disables the client, so an abandoned record at destroy is dropped too. Deferring lets the scenario service's flush(true) call dispatchBeacon() first. Include it if a test with the mocked client shows the order recordEvent then dispatchBeacon then disable.

Decide by test, not by default:

  • UwtTelemetryBroadcastHost (the shell's side). Not needed for this bug; change it only if a test shows a need.

Out of scope:

  • No wire-protocol or message changes.
  • No core changes.
  • BI events (nothing to flush).
  • Non-Angular adapters.
  • The abandonedBy: 'page-hidden' label stays as it is, even when the cause is a destroy.

Acceptance criteria

  • On a real injector destroy, a fragment forwards its in-flight scenario to the shell as one abandoned record, followed by the flush.
  • A line logged from another service's ngOnDestroy reaches the shell's log provider.
  • After the destroy, nothing is left open: the channel is closed, no timer or listener remains, and calls on the destroyed sink or provider send nothing.
  • The result doesn't depend on creation order: it holds whether the scenario service or the sink is created first.
  • With the RUM sink, the destroy order is recordEvent, then dispatchBeacon, then disable.
  • Every existing spec passes unchanged, including those that call ngOnDestroy() by hand.
  • npm run api:check shows no change.

Test plan

New specs, all using a real injector teardown:

  1. The reproduction above.
  2. Another service logs in its own ngOnDestroy, and the line reaches the shell.
  3. The last log flush reaches the shell.
  4. Cleanup after destroy: every stub channel the fragment opened is closed (the stub keeps its channels in a private map, so add a small public count of open ones, or spy on close), jest.getTimerCount() === 0, and a later record or query sends nothing.
  5. Creation order: run test 1 once with the scenario service created first and once with the sink first.
  6. RUM, with the mocked client: call order as in the acceptance criteria.

Mutation checks: put each onDestroy registration back to an immediate dispose() in ngOnDestroy, one class at a time, and confirm a new spec fails. Confirm the compiled mutation, not a compile error, is what fails.

Files

  • projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts (ngOnDestroy at line 41)
  • projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts (line 45)
  • projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts (UwtBroadcastConnectionRef, line 68)
  • projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts (line 45)
  • New specs next to uwt-broadcast.spec.ts, and one for the RUM sink.

Documentation

Keep README and DESIGN.md in step:

  • projects/ngx-ui-watchtower/DESIGN.md: state the teardown order, in the section on multiple realms or under "Packaging and internals". Services flush first; destinations and the channel close last. A custom destination with its own ngOnDestroy should do its final teardown through DestroyRef.onDestroy, or it will be closed before the services' last messages.
  • projects/ngx-ui-watchtower/README.md, "Micro-frontends": one sentence that a fragment unmounted mid-journey records the scenario as abandoned.

Manual verification

In a real shell with a fragment (for example Web Fragments), or a plain page that embeds the fragment bundle and removes it, with a listener logging every message on BroadcastChannel('ngx-ui-watchtower') in the shell:

  1. Start a scenario in the fragment and leave it unsettled.
  2. Unmount the fragment.
  3. Confirm the abandoned record arrives.
  4. Record, in this issue, whether the fragment window fires pagehide before Angular destroys the app. That settles how much this matters in practice.

Release

A fix: commit changes the Angular package only. With independent versions that is a patch release of ngx-ui-watchtower alone; the core is untouched. The PR must reference this issue (Closes #…), as the PR check requires.

Definition of done

npm run lint, format:check, typecheck, build, api:check, test:coverage and smoke pass, the mutation checks are shown to fail, and the manual check is recorded above.

Activity

  1. self-assigned this
    on Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions