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:
provideZoneChangeDetection()atsrc/main.ts:390(plus thezone.jspolyfill atangular.json:25);- 181 explicit
ChangeDetectionStrategy.Eagermarkers (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) (Defaultis now a deprecated alias forEager;OnPushis the v22 default — the app has 55 explicitOnPushcomponents, 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-zoneResizeObservercallbacks. The proof is that the payload is a writable-signal write: signal writes notify Angular'sChangeDetectionSchedulerdirectly, 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 onMicrotaskEmpty → queueMicrotask, 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 zoneless — provideZonelessChangeDetection() 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.
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.