A proposal for discussion. Nothing here has been implemented.
Goal: swapping the component library should mean reimplementing an adapter, not editing 296 files. The same change makes the app more customizable, because whatever the app owns is what a deployment can change.
Where we are
5,424 <ion-*> occurrences across 296 files; 299 files import @ionic/angular/standalone.
- About 62% of that is cosmetic — cards, buttons, icons, grid.
- ~770
ngModel bindings across six control types, with zero custom ControlValueAccessors. Ionic currently defines what a value is.
- Good news: there is no Ionic app shell (no
ion-app, ion-content, ion-router-outlet, ion-menu, ion-tabs). The layout is ours already.
The catch
Wrapping components fixes the source, but the source is the cheap half.
|
Would need changing |
| Unit tests |
~15–20% of 237 files, mostly one-line import swaps |
| Source |
296 files, but ~62% cosmetic |
| E2E |
39 of 52 specs, 16 of which bypass our helper layer entirely |
E2E asserts against rendered DOM, so wrapping components does nothing for it by itself. The boundary has to cover the test seam too, or it is not plug and play.
Proposed design
src/app/ui/ holds app-owned primitives (app-button, app-select, app-input, …). The public API is ours; the vendor lives inside. A swap reimplements that directory.
Three tiers, because they have different answers:
- Cosmetic — card, button, icon, layout. A template swap. High volume, low risk.
- Form controls — each owns its
ControlValueAccessor. This is the important one: it moves the value contract (ISO date strings, select values) into the app, so a swap fails loudly instead of corrupting data quietly. Keeping today's selectors means ~770 bindings move without touching 296 templates.
- Behavioural (select popup, tabs, overlays) — build on
@angular/cdk, which AGENTS.md already retains "for unstyled primitives" and which @angular/cdk/table already uses in 28 files. CDK is headless, so this tier becomes permanently vendor-independent rather than merely swappable.
Test seam: each primitive exposes a stable data-testid and known ARIA, so e2e/utils/ targets our contract instead of Ionic's internals. That is what makes the 39-spec number fall.
Theming: _ionic-theme.scss already maps app tokens onto Ionic's in the right direction. Make that the whole contract, so a deployment is not capped at the current 16 BRANDABLE_TOKENS by what Ionic happens to expose.
Getting there incrementally
The repo has already proven this mechanism twice: @angular/material was banned by no-restricted-imports and reached zero, and @ngx-translate is on a ratchet that has gone 435 → 323.
- Ban
@ionic/angular/* outside src/app/ui/**.
- Seed
eslint-suppressions.json with today's offenders, so nothing breaks now.
--prune-suppressions means the count can only fall; new Ionic imports outside ui/ fail lint immediately.
- Migrate one primitive at a time — tier 3 first (smallest and riskiest), then tier 2, then the test seam, then the cosmetic tail.
Why it is worth doing
We have done this before. The Material → Ionic migration left five dead CSS rules for mat-form-field, eleven files hand-tracking state the old library used to own, and a nav doc that still says "Material icon name". Stale mat-select selectors in the guided tour pointed at nothing until #526.
For scale: scripts/codemod-aria-labels.mjs records that Ionic's 7 → 8 label change alone touched 132 files. That is a point release, without this boundary.
Notes
DOCS/adr/0003-adapter-boundary.md deliberately excluded <ion-*>, while saying the boundary "is built so this can be added later". This is that later. AGENTS.md:84 states the current policy and would need updating too.
- Unrelated but worth knowing: the "323 call sites" in
npm run ga:check is not Ionic. It is 285 ngx-translate call sites plus 15 storage ones; zero are Ionic components. Easy to misread as this problem.
- Assumes one library at a time. Supporting two at once is a much larger design.
A proposal for discussion. Nothing here has been implemented.
Goal: swapping the component library should mean reimplementing an adapter, not editing 296 files. The same change makes the app more customizable, because whatever the app owns is what a deployment can change.
Where we are
5,424<ion-*>occurrences across 296 files; 299 files import@ionic/angular/standalone.ngModelbindings across six control types, with zero customControlValueAccessors. Ionic currently defines what a value is.ion-app,ion-content,ion-router-outlet,ion-menu,ion-tabs). The layout is ours already.The catch
Wrapping components fixes the source, but the source is the cheap half.
E2E asserts against rendered DOM, so wrapping components does nothing for it by itself. The boundary has to cover the test seam too, or it is not plug and play.
Proposed design
src/app/ui/holds app-owned primitives (app-button,app-select,app-input, …). The public API is ours; the vendor lives inside. A swap reimplements that directory.Three tiers, because they have different answers:
ControlValueAccessor. This is the important one: it moves the value contract (ISO date strings, select values) into the app, so a swap fails loudly instead of corrupting data quietly. Keeping today's selectors means ~770 bindings move without touching 296 templates.@angular/cdk, whichAGENTS.mdalready retains "for unstyled primitives" and which@angular/cdk/tablealready uses in 28 files. CDK is headless, so this tier becomes permanently vendor-independent rather than merely swappable.Test seam: each primitive exposes a stable
data-testidand known ARIA, soe2e/utils/targets our contract instead of Ionic's internals. That is what makes the 39-spec number fall.Theming:
_ionic-theme.scssalready maps app tokens onto Ionic's in the right direction. Make that the whole contract, so a deployment is not capped at the current 16BRANDABLE_TOKENSby what Ionic happens to expose.Getting there incrementally
The repo has already proven this mechanism twice:
@angular/materialwas banned byno-restricted-importsand reached zero, and@ngx-translateis on a ratchet that has gone 435 → 323.@ionic/angular/*outsidesrc/app/ui/**.eslint-suppressions.jsonwith today's offenders, so nothing breaks now.--prune-suppressionsmeans the count can only fall; new Ionic imports outsideui/fail lint immediately.Why it is worth doing
We have done this before. The Material → Ionic migration left five dead CSS rules for
mat-form-field, eleven files hand-tracking state the old library used to own, and a nav doc that still says "Material icon name". Stalemat-selectselectors in the guided tour pointed at nothing until #526.For scale:
scripts/codemod-aria-labels.mjsrecords that Ionic's 7 → 8 label change alone touched 132 files. That is a point release, without this boundary.Notes
DOCS/adr/0003-adapter-boundary.mddeliberately excluded<ion-*>, while saying the boundary "is built so this can be added later". This is that later.AGENTS.md:84states the current policy and would need updating too.npm run ga:checkis not Ionic. It is 285 ngx-translate call sites plus 15 storage ones; zero are Ionic components. Easy to misread as this problem.