`invokeInNgZone` — zone re-entry at delivery
@flyze/lib-core-angular 1.0.0-alpha.34· latestStatus: Shipped (2026-08-04). Decision record: ADR 0008. Companion work:
brick-generator-lib/docs/proposals/zone-safe-script-execution.mdfixes the platform’s script execution at its choke point and consumes this operator.
The problem it solves
Section titled “The problem it solves”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:
brick-generator-lib/.../brick-runtime/src/lib/brick-runtime-abstract.component.ts—#invokeInNgZone, no in-zone guard: every emission paid anngZone.run()even when already in-zone.flyze-admin-app/.../application-runtime-skeleton/application-runtime-skeleton.component.ts—#invokeInNgZone, with guard. The shipped operator has these semantics.flyze-admin-app/.../dynamic-groups/dynamic-groups.component.ts—#runCdr, 4 call sites, and amap((v) => ngZone.run(() => v))projector wrap — the exact anti-pattern ADR 0008 rules out: it ticks before delivery and never re-enters the zone forerror/complete. Migrating it is a behavior fix, not a pure refactor.
How it works
Section titled “How it works”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 delivery — subscriber.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.
Placement
Section titled “Placement”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 }));Zoneless Angular
Section titled “Zoneless Angular”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.
Visibility in scripting type bundles
Section titled “Visibility in scripting type bundles”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.
Verification
Section titled “Verification”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 withNgZone.isInAngularZone() === trueinside the subscriber. - An emission made inside
ngZone.run(...)does not callngZone.runagain (spy) — verifies the guard by observation; deleting the guard fails this spec only. errorandcompleteare 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).
Migration
Section titled “Migration”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.