Repository navigation
Description
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
abandonedbyUwtScenarioTelemetryService.ngOnDestroy, but the record never reaches the shell. - The final
log-flush, and any line logged by another service'sngOnDestroy, 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()):
destroy()sets_destroyed = true.- It calls every
ngOnDestroy, dependencies first. - Only then does it run the
DestroyRef.onDestroycallbacks.
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 coredispose(), which settles in-flight scenarios asabandonedand callssink.flush(true).UwtLoggerService.ngOnDestroy()calls the coredispose(), which callslogApi.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
ngOnDestroythrows, because_destroyedis alreadytrueby then. DestroyRef.destroyedexists in Angular 21 (the supported range is 21 to 22) and istruewhile 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 callsuper.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
/rumentry would need yet another private copy of it (seeuwt-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
OnDestroyfrom these classes: a public API change, and it breaks subclasses that callsuper.ngOnDestroy(). - Defer with
queueMicrotaskorsetTimeout: non-deterministic, and leaks in tests. - Flush in
ApplicationRef.onDestroy: only works when the app is destroyed throughApplicationRef, 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: itsngOnDestroydisables the client, so an abandoned record at destroy is dropped too. Deferring lets the scenario service'sflush(true)calldispatchBeacon()first. Include it if a test with the mocked client shows the orderrecordEventthendispatchBeaconthendisable.
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
abandonedrecord, followed by the flush. - A line logged from another service's
ngOnDestroyreaches 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, thendispatchBeacon, thendisable. - Every existing spec passes unchanged, including those that call
ngOnDestroy()by hand. -
npm run api:checkshows no change.
Test plan
New specs, all using a real injector teardown:
- The reproduction above.
- Another service logs in its own
ngOnDestroy, and the line reaches the shell. - The last log flush reaches the shell.
- 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 laterrecordorquerysends nothing. - Creation order: run test 1 once with the scenario service created first and once with the sink first.
- 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(ngOnDestroyat 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 ownngOnDestroyshould do its final teardown throughDestroyRef.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:
- Start a scenario in the fragment and leave it unsettled.
- Unmount the fragment.
- Confirm the abandoned record arrives.
- Record, in this issue, whether the fragment window fires
pagehidebefore 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