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, drop zone imports from vitest-setup.ts, move provideZonelessChangeDetection() into test-providers.ts (this is where the ~35KB bundle win lands and reverting gets expensive). 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.

Standing gates (every PR)

Full vitest suite green, coverage floors unmoved (50/40/50/50); production build green; per-spec provideZonelessChangeDetection() added to every touched component's spec; 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.