Skip to content

`invokeInNgZone` — zone re-entry at delivery

@flyze/lib-core-angular 1.0.0-alpha.34· latest

Status: Shipped (2026-08-04). Decision record: ADR 0008. Companion work: brick-generator-lib/docs/proposals/zone-safe-script-execution.md fixes the platform’s script execution at its choke point and consumes this operator.

An observable that emits outside the Angular zone does not render under zone-based change detection: the AsyncPipe calls markForCheck(), which only marks the view — the tick that would check it never comes, because no zone event follows. The UI stays stale until an unrelated interaction. Real producers of out-of-zone emissions in this workspace: the user-scripting runtime in brick-generator-lib, and third-party libraries that escape the zone deliberately (angular-gridster2 drag/resize stop-callbacks, lib-graph’s echarts continuations) and then emit through synchronous rxjs Subjects.

Before this operator, the workaround existed as three diverging private copies:

  1. brick-generator-lib/.../brick-runtime/src/lib/brick-runtime-abstract.component.ts#invokeInNgZone, no in-zone guard: every emission paid an ngZone.run() even when already in-zone.
  2. flyze-admin-app/.../application-runtime-skeleton/application-runtime-skeleton.component.ts#invokeInNgZone, with guard. The shipped operator has these semantics.
  3. flyze-admin-app/.../dynamic-groups/dynamic-groups.component.ts#runCdr, 4 call sites, and a map((v) => ngZone.run(() => v)) projector wrap — the exact anti-pattern ADR 0008 rules out: it ticks before delivery and never re-enters the zone for error/complete. Migrating it is a behavior fix, not a pure refactor.
export function invokeInNgZone<VALUE>(ngZone: NgZone): MonoTypeOperatorFunction<VALUE>;

Source: projects/lib-core-angular/src/lib/rxjs/invoke-in-ng-zone.operator.ts, exported through the src/lib/rxjs barrel — the library’s rxjs category, established by this operator.

The operator mirrors the source and wraps deliverysubscriber.next / error / complete — in ngZone.run(), guarded by NgZone.isInAngularZone(). Both properties are load-bearing; a review “simplification” of either reintroduces intermittent stale-UI bugs. Why delivery rather than a projector, and why the guard, is recorded in ADR 0008.

The zone is a parameter on purpose: the operator works both from injection contexts (invokeInNgZone(inject(NgZone))) and from Injector-based resolution (invokeInNgZone(this.injector.get(NgZone)), which is how brick-generator-lib’s runtime component resolves its zone), and it keeps the dependency visible at the call site. An inject()-based zero-arg overload was deliberately not built.

Apply it as the last operator before any sharing. With multiple async pipes on one stream, follow it with shareReplay({ bufferSize: 1, refCount: true }) so one emission enters the zone once and triggers one change detection cycle:

readonly state$ = this.store.state$.pipe(
invokeInNgZone(this.ngZone),
shareReplay({ bufferSize: 1, refCount: true })
);

Under provideZonelessChangeDetection() (stable in v20, the default for new apps since v21) NgZone is a noop: isInAngularZone() is false and run() just invokes the handler — the operator degrades to a pass-through, harmless but dead. It is a zone-era bridge: delete it from the pipe when the consuming application goes zoneless. Zone-based mode has no removal date; consumers here are on Angular 17.

brick-generator-lib and flyze-admin-app inline this library into their bundled scripting .d.ts via --inlinedLibraries (brick-generator-lib’s bundle:dts and ci:bundle:dts scripts, and flyze-admin-app’s equivalent), so the operator’s type surfaces in the generated scripting type bundles. Intended.

invoke-in-ng-zone.operator.spec.ts next to the source. The trap: Jasmine spec bodies run in the ProxyZone, where isInAngularZone() is already false — every in-zone case wraps in zone.run(...). Precedent with explanation: fyz-tooltip.directive.spec.ts:1093-1108 (C24). A second trap the spec documents: Angular’s change-detection scheduler is subscribed to the TestBed zone’s onMicrotaskEmpty and calls run() itself on every zone exit, so the guard spec uses a private NgZone instance to keep its spy count meaningful.

Covered:

  • An emission from runOutsideAngular(...) is delivered with NgZone.isInAngularZone() === true inside the subscriber.
  • An emission made inside ngZone.run(...) does not call ngZone.run again (spy) — verifies the guard by observation; deleting the guard fails this spec only.
  • error and complete are delivered in-zone from an out-of-zone source.
  • Unsubscribing the result unsubscribes the source (teardown spy).

End-to-end behavior is verified by the consumers (the companion proposal’s gridster-resize scenario).

Consumers migrate on their own schedules: brick-generator-lib per its companion proposal (which carries a same-signature local fallback until the published alpha is available), then flyze-admin-app — the skeleton component is a drop-in import swap; dynamic-groups is the behavior fix described above and should be re-verified after the swap.