Skip to content

AF2 Phase 5: app-wide zoneless change detection

The final phase of the AF2 program (Phase 4 plan): run the whole web app on zoneless change detection and remove the zone.js polyfill. Everything here derives from a full audit of the app performed 2026-08-11 against a genuine Angular 22.1.0 install.

The framing that matters

This is not a forward migration — it is the removal of two compatibility opt-outs. On Angular 22, zoneless is the framework default; provideZonelessChangeDetection's own docstring says zoneless is enabled by default in v21+. This app explicitly opts out in two stamped layers left by an ng update compatibility migration:

  1. provideZoneChangeDetection() at src/main.ts:390 (plus the zone.js polyfill at angular.json:25);
  2. 181 explicit ChangeDetectionStrategy.Eager markers (grep -rn "ChangeDetectionStrategy\.Eager" src/services/web/src, 181 as of 2026-08-11 HEAD — re-run this before treating it as done in wave 2) (Default is now a deprecated alias for Eager; OnPush is the v22 default — the app has 55 explicit OnPush components, AF2 among them).

Each unit of work is therefore a greppable, individually-revertable deletion of an opt-out, with the failure mode to hunt being the inverse of today's: zoneless fails by silently not rendering, not by over-checking.

Why this is tractable

The codebase is already most of the way there: 197 selectSignal, 229 computed, 84 signal(, 22 toSignal, 390 | async (all safe — AsyncPipe calls markForCheck() on every emission, a documented zoneless scheduling trigger), zero setInterval, zero fakeAsync/tick() in specs, and only:

  • 17 NgZone call-sites, of which 11 are either a feature-toggled change-detection visualiser (blink(), six components) or zone wrappers around signal writes (layout.service, progress-smoother.service) that are safe to delete even though they are not no-ops under today's zone bootstrap — they still guarantee a zone tick for out-of-zone ResizeObserver callbacks. The proof is that the payload is a writable-signal write: signal writes notify Angular's ChangeDetectionScheduler directly, which has been zone-independent since v16, so CD is scheduled with or without the wrapper, both today and after the flip;
  • 24 subscribe-and-assign defects (.subscribe(x => (this.field = x)) with no CD scheduling) out of 136 total subscriptions — half of them in one copy-pasted pair (study-table + stage-studies);
  • 43 setTimeouts, a minority of which write template state.

AF2 is the reference implementation and is confirmed zoneless-clean with no exceptions: 23 components, all OnPush, zero NgZone / async pipes / subscriptions / ChangeDetectorRef across the whole directory, afterNextRender where DOM timing matters, and Handsontable hooks routed through output(). Its perf gate provides a rare quantitative regression net for the flip.

The hard items (all have in-repo fixes; no hard blockers)

Item Why it is hard Fix
Forked Material sidenav (core/syrf-material/sidenav/drawer.ts) _ngZone.onMicrotaskEmpty (:1022) never emits under NoopNgZone (verified in the installed runtime), so _validateDrawers() silently stops running; _ngZone.run publishing _contentMargins (:963) stops scheduling CD. Present on every project page. Must land before the flip. The fork exists to add a collapsible (mini/rail) mode — a collapsed state with a fixed collapsed width — which stock MatDrawer (over\|push\|side) does not offer. First choice: retire the fork and rebuild the collapsed rail on stock Material 22 using only its public surface (a side-mode drawer whose width animates between rail and full width via CSS + a width binding — the standard mini-variant approach; upstream has no first-party rail mode). The wave-2 retire-or-port evaluation must confirm the rail UX survives on stock before deleting. Fallback: keep the fork, port onMicrotaskEmptyqueueMicrotask, convert the _contentMargins publication, drop four runOutsideAngulars.
@handsontable/angular-wrapper 18 wrapHooksInNgZone routes every grid hook through ngZone.run (read from the shipped bundle) — a permanent no-op under zoneless, unpatched upstream. Repo convention (not just a fix): a HOT hook may only write signals or emit through output(). All three grid sites already sit on the 18.x @handsontable/angular-wrapper as of PR #2733 (Phase 4 PR 3) — this wave does not re-migrate them; it converts the two legacy hosts' hook payloads (timepoint-spreadsheet, outcome-table-info) to the signal/output() discipline that AF2's outcome-timepoint-grid (built compliant in #2733/#2741) already follows. The convention has no mechanical enforcement today — a reviewer noted this — so wave 2's scope adds an ESLint guard (no-restricted-syntax or a custom rule) flagging HOT hook registrations whose callbacks assign to this.* fields; this PR only records the requirement, the rule itself lands in wave 2.
AF v1 timer choreography annotation-unit.component.ts:468-470 (nested setTimeout + manual detectChanges), five timers in annotation-form.component.ts, focus timers in annotation-question.component.ts:322 etc. Re-derive with afterNextRender, don't translate timer-by-timer.
Test-coverage gap The components with the largest zone exposure are disproportionately the ones excluded from vitest (all of AF v1, pdf-tools, most of project-admin, create-question). Wave 2 verification leans on targeted Playwright coverage + budget for writing tests, not just changes.

Third-party posture (all verified against shipped bundles): Material/CDK 22 — supported, no action; SignalR — injects no NgZone, events flow via ngrx into selectSignal/| async, safe by construction; Highcharts, Auth0, Sentry, APM, ngrx family — no action; angular-auth-oidc-client — background token-poll zone.runs degrade to store dispatches (safe, verify on soak); @ngbracket/ngx-layout — fx* directives style imperatively (safe), only direct MediaObserver template bindings need checking.

Waves

There is no per-route zonelessprovideZonelessChangeDetection() is application-wide. Incremental confidence comes from per-spec provideZonelessChangeDetection() in TestBed (Angular's documented mechanism) and from splitting the flip from the polyfill removal.

Wave Scope Size Verification
1a Groundwork, mechanical: delete the six blink() runOutsideAngular wrappers + NgZone injections; delete the redundant zone wrappers in layout.service and progress-smoother.service (payloads are signal writes — consumers verified); delete _appRef.tick() (annotation-question.component.ts:552); fix the one load-bearing app-code run()editable-text-display.component.ts:140 FocusMonitor → signal. Fix the NgZone-mocking specs. ~1-2 days Full vitest suite; per-spec zoneless providers on touched components. Then one uncommitted CI-cost probe: run the suite once with provideZonelessChangeDetection() global in test-providers.ts — the failure list is the empirical map for wave 2.
1b Convert the 24 subscribe-and-assign sites to selectSignal/toSignal/| async, starting with the study-table + stage-studies twins (12 of 24). ~2 days Per-spec zoneless providers; full suite.
2 The hard residue: sidenav fork (retire-or-port decision), two legacy Handsontable hosts, AF v1 timer choreography via afterNextRender, shared form controls sweep (select ×3, checklist, input-array), project-nav/chips-emails-input manual-CD smells; an ESLint guard (no-restricted-syntax or a custom rule) mechanically enforcing the HOT-hook rule (a HOT hook may only write signals or emit through output()), flagging hook callbacks that assign to this.* fields. ~1-1.5 weeks Per-spec zoneless where specs exist; targeted Playwright for the untested surfaces (timepoint spreadsheet edit, outcome table, sidenav open/close/resize); the wave-1 probe rerun trending to zero failures; the ESLint guard itself passing lint on the two converted legacy hosts.
3a The flip: provideZoneChangeDetection()provideZonelessChangeDetection() at main.ts:390, leaving the zone.js polyfill in place (explicitly supported) — behaviourally complete, instantly revertable one-liner. Soak on preview, then production. 1 line + soak Four gates: full e2e smoke incl. multi-user-screening (SignalR push with no local interaction — the direct probe for silent non-render); AF2 perf gate re-run on the zoneless build; manual pass of the visualiser-flagged surfaces; Sentry/APM watch during soak.
3b Only after clean soak: remove zone.js from angular.json and drop the zone imports from vitest-setup.ts (this is where the bundle win lands and reverting gets expensive). The provideZonelessChangeDetection() move into test-providers.ts is already done — it was pulled forward ahead of the app flip on 2026-09-08. Keep vitest.config.ts exclude lists in sync with angular.json (they must match — both files say so). ~1-2 days Full suite green under global zoneless test providers; expected failures dominated by autoDetect assertion changes.

Test-infra sequencing: (1) per-spec opt-in as each component converts — evidence, not hope; (2) global flip in test-providers.ts; (3) drop zone.js/zone.js/testing from vitest-setup.ts last, independent of the app-side polyfill. Steps 1 and 2 are complete: the global flip landed on 2026-09-08 and the 56 per-spec declarations were removed with it, so only step 3 remains.

Wave 3a as delivered: the switch is per environment, not a one-liner

The wave-3a row above says "one-line flip". The 2026-09-07 zoneless readiness report (§3.3, held outside the repository with the other handover notes) replaced that with a per-deployment selection, because main.ts already fetches appConfig.env.json before bootstrapApplication, and that file is produced per deployment by envsubst at container start. Staging can therefore soak zoneless on the exact artefact production runs, and reverting is a config redeploy rather than a rebuild.

Mechanism. zonelessChangeDetection is a generated, default-off Boolean feature flag (SYRF__FeatureFlags__ZonelessChangeDetection, Helm value featureFlags.zonelessChangeDetection). provideChangeDetection() in src/app/core/change-detection/change-detection-providers.ts maps it to provideZonelessChangeDetection() or, by default, provideZoneChangeDetection(). Because the provider is chosen once at bootstrap, the flag is catalogued as pageReload in RuntimeFeatureFlagCatalog.

Rollout order. Staging first (featureFlags.zonelessChangeDetection: true in both the staging API and web values in cluster-gitops), soak against the wave-3a gates in the table above, then production. Revert is the same value set back to false in both services and redeployed — no rebuild, no image change. The API snapshot is authoritative at browser bootstrap; setting only the web value is insufficient. Clear any existing runtime override when returning control to deployed values. A non-production administrator can also flip it through the runtime feature-flag override API; the change takes effect on the next page load.

Expect NG0914 while the switch exists. zone.js stays in angular.json until wave 3b, so a zoneless deployment loads a polyfill it does not use and Angular logs the NG0914 warning. That is supported and harmless; it is the price of an instantly revertable flip, and the ~12 KB gzipped bundle win only arrives with wave 3b.

provideCheckNoChangesConfig({ exhaustive: true }) is a development-build tool only. provideChangeDetection() registers it when zoneless is selected in a non-production build. Angular's implementation is ngDevMode-gated (@angular/core 22.1.0, provideCheckNoChangesConfig returns empty providers in an optimized build), so it cannot contribute to the staging soak, which runs the production build. It covers ng serve and any development build. The report's suggestion to run it with an interval during the soak is therefore not reachable without a second, non-optimized build artefact — which §3.3 explicitly does not recommend.

Standing gates (every PR)

Full vitest suite green, coverage floors unmoved (50/40/50/50); production build green; no per-spec provideZonelessChangeDetection() — the suite supplies it globally from test-providers.ts; docs updated in-PR; each PR revertable on its own. The AF2 perf budgets are binding at wave 3a.

Environment note

main's src/services/web/node_modules can be stale — every version-specific claim above was verified against a genuine 22.1.0 install. Run pnpm install in a fresh worktree before relying on local runs.