All features

Angular 22, and the plugins that would have frozen

Try the real interface

Explore this workspace.

Real Torbie UI · mock sessions
Tab bar
Loading Torbie…
Technical background

Upstream is on Angular 15 and TypeScript 4.9; this runs on Angular 22 and TypeScript 6. Most of that upgrade was ordinary — module resolution that predated the exports field, a class-field semantic that changes what a field initializer may call, a webpack optimisation that produced a module with a null id. Two of them were not: Angular 22 makes OnPush the default change-detection strategy and standalone the default for components, and a Tabby plugin declares neither. The first meant the app booted, built its whole component tree, and rendered one element. The second meant a plugin's own NgModule was refused and the plugin simply did not load — eighteen instead of twenty-one, with nothing in the diagnostics log, because nothing had failed to resolve. Both defaults are restored on the one shared @angular/core object that every builtin and every plugin reaches the decorator through, rather than on this project's own eighty-seven files, which would have fixed our interface and silently frozen theirs.

The problem

Upstream sits on Angular 15 and TypeScript 4.9, which is two majors behind the TypeScript everything else in this lab uses and well past the point where the ecosystem still ships types for it. Moving is not optional forever. But this codebase is also the host for third-party plugins written years ago, and a framework upgrade that quietly changes what a decorator means breaks every one of them at once.

How it works

The tree is on Angular 22.1.5 and TypeScript 6.0.3. Four of the six blockers were ordinary: moduleResolution that predated the exports field every modern package publishes its subpaths through; useDefineForClassFields, which changes what a class field is and broke sixty-four field initializers that call a constructor parameter property; a bootstrap option that no longer exists and was silently ignored rather than rejected, leaving the app running zoneless; and webpack scope-hoisting Angular's chunked ESM into a reference to a module with a null id. The last two are the interesting ones, and both are restored in a single place.

Details
  • Angular 22 makes OnPush the default change-detection strategy. ChangeDetectionStrategy gained Eager for the old CheckAlways and demoted Default to a deprecated alias of it, and the compiler reads changeDetection ?? OnPush. The app booted, loaded every plugin, built its whole component tree — and rendered one element.
  • The obvious diagnosis was wrong, and measuring is what corrected it. "Nothing schedules the first pass" fits the symptom exactly. But the zone was real, its inner zone was angular, the scheduler was subscribed to that exact instance, and it emitted. A full ApplicationRef.tick() still changed nothing while the debug API's applyChanges() took the DOM from 1 element to 78 — and the only difference between them is that applyChanges marks the view dirty first, which is the signature of a view Angular no longer treats as CheckAlways.
  • standalone now defaults to true as well, which is the same change in a second place and fails much louder. A plugin declares its components in its own NgModule; Angular refuses them, the module throws, and the plugin does not load. Measured: eighteen plugins instead of twenty-one, with nothing in diagnostics.log, because nothing had failed to resolve.
  • Both defaults are restored on the shared module map, and that placement is the whole point. Third-party plugins are the reason this fork exists; they are JIT — none of the three installed here ships a compiled component definition, they call the decorator at runtime — and the plugin webpack config marks @angular/* external, so every builtin and every plugin reaches the decorator through one object. Annotating this project's own eighty-seven files would have fixed its interface and silently frozen theirs.
  • Only absent keys are filled in, so the five components that ask for OnPush deliberately still get it. Libraries are unaffected either way: @ng-bootstrap and @angular/cdk are partial-compiled and go through the linker, which picks its defaults from the Angular version each was built against.
  • Measured after: the window renders 91 elements unaided — the same count as the Angular 15 build — and a forced pass then changes nothing, which is what a fully-rendered application looks like. Twenty-one plugins load, all three third-party ones among them, every builtin they require resolves, and their config providers ran.
What this does not claim
  • This is not a zoneless migration. Zone-based change detection is asked for explicitly rather than inherited. Going properly zoneless is a real piece of work this codebase has not had — no signals, no markForCheck discipline, and a great deal of state mutated from xterm callbacks and IPC handlers.
  • Scope hoisting is off, which is an optimisation given up rather than a problem solved. It should be re-enabled when a later webpack or Angular stops producing the null module id — and only with a boot to prove it.
  • TypeScript is at 6.0.3 because that is the whole of Angular's peer range. TypeScript 7 remains unreachable until 7.1 restores the compiler API surface Angular builds on; that is a finding recorded against the pin, not a version anyone chose.
Source commits
RobustnessAdded 2026-09-08172 files+1,620 / −2,136 lines