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.
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.