From 9fd6c6fde3f7ac52451306ab606db660762891e3 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 13 Aug 2026 12:51:29 -0700 Subject: [PATCH 1/4] feat(migrate): cover the remaining v9 breaking changes --- packages/migrate/README.md | 69 ++++--- packages/migrate/src/ast/text-scan.ts | 31 ++- packages/migrate/src/detect.ts | 26 ++- packages/migrate/src/main.ts | 6 +- packages/migrate/src/migrations/index.ts | 28 +++ .../src/migrations/v9/angular-css-tilde.ts | 5 +- .../src/migrations/v9/angular-ionic-module.ts | 41 ++++ .../v9/angular-module-resolution.ts | 75 +++++++ .../src/migrations/v9/angular-typescript.ts | 17 ++ .../src/migrations/v9/angular-version.ts | 77 ++++++++ .../src/migrations/v9/core-autocorrect.ts | 8 +- .../src/migrations/v9/core-browserslist.ts | 87 +++++++++ .../src/migrations/v9/core-capacitor.ts | 44 +++++ .../migrate/src/migrations/v9/core-deps.ts | 18 ++ .../src/migrations/v9/core-floating-label.ts | 86 ++++++++ .../src/migrations/v9/core-form-structure.ts | 106 ++++++++++ .../src/migrations/v9/core-modal-handle.ts | 99 ++++++++++ .../src/migrations/v9/core-package-exports.ts | 54 ++++++ .../src/migrations/v9/core-select-events.ts | 78 ++++++++ .../migrations/v9/core-swipe-back-config.ts | 31 +++ .../src/migrations/v9/core-textarea-height.ts | 43 ++++ .../migrate/src/migrations/v9/react-deps.ts | 16 +- .../src/migrations/v9/react-router-6-code.ts | 29 ++- .../migrate/test/angular-ionic-module.test.ts | 51 +++++ .../test/angular-module-resolution.test.ts | 94 +++++++++ .../migrate/test/angular-typescript.test.ts | 33 ++++ packages/migrate/test/angular-version.test.ts | 55 ++++++ packages/migrate/test/core-behavior.test.ts | 183 ++++++++++++++++++ .../migrate/test/core-browserslist.test.ts | 75 +++++++ packages/migrate/test/core-capacitor.test.ts | 33 ++++ .../migrate/test/core-floating-label.test.ts | 75 +++++++ .../migrate/test/core-form-structure.test.ts | 93 +++++++++ .../migrate/test/core-package-exports.test.ts | 43 ++++ .../migrate/test/core-textarea-height.test.ts | 26 +++ packages/migrate/test/engine.test.ts | 44 ++++- packages/migrate/test/migrations.test.ts | 128 ++++++++++++ 36 files changed, 1958 insertions(+), 49 deletions(-) create mode 100644 packages/migrate/src/migrations/v9/angular-ionic-module.ts create mode 100644 packages/migrate/src/migrations/v9/angular-module-resolution.ts create mode 100644 packages/migrate/src/migrations/v9/angular-typescript.ts create mode 100644 packages/migrate/src/migrations/v9/angular-version.ts create mode 100644 packages/migrate/src/migrations/v9/core-browserslist.ts create mode 100644 packages/migrate/src/migrations/v9/core-capacitor.ts create mode 100644 packages/migrate/src/migrations/v9/core-deps.ts create mode 100644 packages/migrate/src/migrations/v9/core-floating-label.ts create mode 100644 packages/migrate/src/migrations/v9/core-form-structure.ts create mode 100644 packages/migrate/src/migrations/v9/core-modal-handle.ts create mode 100644 packages/migrate/src/migrations/v9/core-package-exports.ts create mode 100644 packages/migrate/src/migrations/v9/core-select-events.ts create mode 100644 packages/migrate/src/migrations/v9/core-swipe-back-config.ts create mode 100644 packages/migrate/src/migrations/v9/core-textarea-height.ts create mode 100644 packages/migrate/test/angular-ionic-module.test.ts create mode 100644 packages/migrate/test/angular-module-resolution.test.ts create mode 100644 packages/migrate/test/angular-typescript.test.ts create mode 100644 packages/migrate/test/angular-version.test.ts create mode 100644 packages/migrate/test/core-behavior.test.ts create mode 100644 packages/migrate/test/core-browserslist.test.ts create mode 100644 packages/migrate/test/core-capacitor.test.ts create mode 100644 packages/migrate/test/core-floating-label.test.ts create mode 100644 packages/migrate/test/core-form-structure.test.ts create mode 100644 packages/migrate/test/core-package-exports.test.ts create mode 100644 packages/migrate/test/core-textarea-height.test.ts diff --git a/packages/migrate/README.md b/packages/migrate/README.md index 6b8fcd897d9..e301259ad62 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -47,38 +47,48 @@ Every breaking change is one of two kinds: | --- | --- | --- | | `@ionic/angular` -> `@ionic/angular/lazy`, `/standalone` -> `@ionic/angular` | Angular | auto | | `@ionic/angular` package bump | Angular | auto | +| `moduleResolution: "node"` -> `"bundler"` in `tsconfig*.json` | Angular | auto | +| TypeScript raised to the 5.4 floor | Angular | auto | | CSS `~` prefix removal in `@ionic/angular` imports | Angular | auto | | Add `provideZoneChangeDetection()` to a standalone bootstrap (keep Zone.js) | Angular | auto | | NgModule bootstrap zone provider | Angular | report | -| `@ionic/react` + React Router v6 bumps, drop `@types/react-router*` | React | auto | +| `IonicModule` deprecation (`provideIonicAngular()`) | Angular | report | +| Angular below the 18 floor | Angular | report | +| Angular 22's `OnPush` default and its Node floor | Angular | report | +| `@ionic/angular-toolkit` version bump | Angular | report | +| `@ionic/react` + React 18 + React Router v6 bumps, drop `@types/react-router*` | React | auto | | `` removal, `component={X}` -> `element={}` | React | auto | -| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, `history` prop, regex paths | React | report | +| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, route children, `history` prop, regex paths | React | report | | `@ionic/vue` + Vue Router 5 + Vue 3.5 bumps | Vue | auto | | `next()` in navigation guards | Vue | report | +| `@ionic/core` package bump | all | auto | | `autocorrect="off"` on `ion-input`/`ion-searchbar` | all | auto | +| `browserslist` entries raised to the v9 browser floors | all | auto | | Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | all | report | | `ion-img` deprecation | all | report | | `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | all | report | - -### What it can't detect - -Some v9 breaks are runtime behavior changes with no reliable source signal, so -the tool leaves them out rather than guess. Check these by hand against the -[migration guide](https://ionicframework.com/docs/updating/9-0): - -- `ion-modal`'s `handleBehavior` now defaults to `"cycle"`. A sheet modal with a - handle becomes focusable and cycles its breakpoints. Set `handleBehavior="none"` - to keep the handle inert. -- `ion-select`'s `ionChange` only fires on an actual change now, and the action - sheet's `selected` role is gone. Code that ran on every confirmation, or read - that role, needs a look. -- Platform detection no longer honors Capacitor 2's `isNative` flag, so a - Capacitor 2 app reports web from `isPlatform('capacitor')` and `'hybrid'`. - Upgrade to Capacitor 7 or later. -- In React and Vue the `swipeBackEnabled` config is read once when the outlet - mounts. If you toggle it at runtime, move to the `swipeGesture` prop on - `ion-router-outlet`. Setting it once at startup still works, so most apps need - no change, which is why we don't flag it. +| `@ionic/core` imports outside the new `exports` allowlist | all | report | +| Capacitor 2 no longer detected as a native platform | all | report | +| `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | all | report | +| `label-placement="floating"` with slotted start/end content | all | report | +| `ion-textarea` md min-height `56px` -> `72px` | all | report | +| `ion-modal` `handleBehavior` default (`"none"` -> `"cycle"`) | all | report | +| `ion-select` `ionChange` firing and the action sheet `selected` role | all | report | +| `swipeBackEnabled` config, now read once at outlet mount | all | report | + +### What you check by hand + +The tool can't point at the code these changes affect, so check them against the +[migration guide](https://ionicframework.com/docs/updating/9-0) yourself: + +- React Router v6 needs a `/*` suffix on any route whose element contains nested + routes or a child `IonRouterOutlet` (`path="/tabs"` -> `path="/tabs/*"`). + Knowing which routes those are means resolving each `element` back to what it + renders, so flagging every route without a suffix would be noise. +- Angular 22 defaults components to `OnPush`, so state mutated as a plain field + in an Ionic lifecycle hook stops re-rendering. The report flags Angular 22 in + `package.json`, but it doesn't find the affected components - `ng update` has a + migration for that. ## How it works @@ -88,7 +98,7 @@ the tool leaves them out rather than guess. Check these by hand against the 3. Apply the auto-fixes and collect the report-only findings. 4. Print a grouped summary of what was fixed and what's left for you. 5. Format the changed files with the project's own Prettier, so the AST-based - edits come out as clean diffs. Pass `--no-format` to skip it. + edits match the surrounding style. Pass `--no-format` to skip it. 6. Reinstall dependencies (using the lockfile's package manager) so `node_modules` matches the bumped `package.json`. Pass `--no-install` to skip it, then reinstall yourself before starting the app. @@ -107,8 +117,19 @@ literals, comments, or unrelated code. - Angular inline templates (a `template:` string in a decorator) and `.js`/`.jsx` files are report-only for template changes. The auto-fix covers external `.html`, `.vue`, and `.tsx`. -- `ion-img` is report-only. It's a deprecation, not a v9 break. +- The `ion-img` deprecation is report-only. - The template scanner is best-effort, not a full HTML parser. +- The component DOM/shadow-part changes are report-only. The right replacement + depends on what the CSS rule was doing, and `ion-select`'s `part="inner"` has + none at all. +- Stylesheet scanning covers `.css` and `.scss` files. Styles inlined in a + component decorator's `styles` array aren't read. +- Only a `.browserslistrc` or `browserslist` file is read, so an app that keeps + the list in `package.json` (the CRA and Vite starters do) needs its browser + floors raised by hand. +- Angular's `moduleResolution` fix skips a tsconfig whose `module` is CommonJS. + TypeScript rejects `bundler` resolution there, and a Node-side config doesn't + resolve `@ionic/angular` subpaths anyway. ## Extending diff --git a/packages/migrate/src/ast/text-scan.ts b/packages/migrate/src/ast/text-scan.ts index c52c3e5ee8a..ff62b2f1ba6 100644 --- a/packages/migrate/src/ast/text-scan.ts +++ b/packages/migrate/src/ast/text-scan.ts @@ -1,25 +1,32 @@ import type { MigrationContext } from '../context.js'; import type { Finding } from '../types.js'; +/** + * What a {@link scanLines} matcher says about a line. A bare string is the + * detail, and the finding inherits the migration's `docsUrl`. The object form + * overrides it, for a migration spanning several sections of the guide. + */ +export type ScanMatch = string | { detail: string; docsUrl: string }; + /** * Scan matching files line by line, emitting a {@link Finding} for every line - * the `matcher` returns a detail string for. Used by report-only migrations - * where precise locations matter more than AST fidelity. + * the `matcher` returns a match for. Used by report-only migrations where + * precise locations matter more than AST fidelity. */ export function scanLines( ctx: MigrationContext, globs: string[], - matcher: (line: string) => string | undefined + matcher: (line: string) => ScanMatch | undefined ): Finding[] { const findings: Finding[] = []; for (const filePath of ctx.glob(globs)) { const text = ctx.readFile(filePath); if (text === undefined) continue; text.split('\n').forEach((line, i) => { - const detail = matcher(line); - if (detail !== undefined) { - findings.push({ filePath, line: i + 1, detail }); - } + const match = matcher(line); + if (match === undefined) return; + const { detail, docsUrl } = typeof match === 'string' ? { detail: match, docsUrl: undefined } : match; + findings.push({ filePath, line: i + 1, detail, ...(docsUrl ? { docsUrl } : {}) }); }); } return findings; @@ -27,3 +34,13 @@ export function scanLines( /** Common file globs for source that may contain templates/markup. */ export const SOURCE_GLOBS = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.html', '**/*.vue']; + +/** Stylesheets, for the breaks that only surface in an app's own CSS. */ +export const STYLE_GLOBS = ['**/*.css', '**/*.scss']; + +/** + * Markup that can hold Ionic elements with attributes worth reading. Includes + * `.jsx` so a JavaScript React app gets the report-only template migrations, + * the coverage `context.ts` gives it in place of the ts-morph ones. + */ +export const TEMPLATE_GLOBS = ['**/*.html', '**/*.vue', '**/*.tsx', '**/*.jsx']; diff --git a/packages/migrate/src/detect.ts b/packages/migrate/src/detect.ts index b84cda50315..2b570f4a6b1 100644 --- a/packages/migrate/src/detect.ts +++ b/packages/migrate/src/detect.ts @@ -8,10 +8,17 @@ export interface DetectedFramework { major: number; } -const FRAMEWORK_PACKAGES: Record, string> = { +/** + * The package that identifies each framework. `core` is listed too: a vanilla + * app has no binding package, so without it the run exits before any `core` + * migration sees the project. A framework app that pins `@ionic/core` directly + * detects both, and that pin has to reach v9 too. + */ +const FRAMEWORK_PACKAGES: Record = { angular: '@ionic/angular', react: '@ionic/react', vue: '@ionic/vue', + core: '@ionic/core', }; /** Extract the major version from a semver range like `^8.4.1` or `~9.0.0-rc.1`. */ @@ -35,6 +42,18 @@ export function isPlainSemverRange(range: string): boolean { return /^\s*[\^~>=<]*\s*\d/.test(range); } +/** + * The major to migrate from. A binding package wins over `@ionic/core` when both + * are declared: the single-shot migrations key off the binding, so an app on a + * v9 binding that never bumped its own `@ionic/core` pin is already migrated, + * and taking the lowest major would re-select them and corrupt its imports. + */ +export function sourceMajor(detected: DetectedFramework[]): number | undefined { + const bindings = detected.filter((d) => d.framework !== 'core'); + const gating = bindings.length > 0 ? bindings : detected; + return gating.length > 0 ? Math.min(...gating.map((d) => d.major)) : undefined; +} + /** * Determine which Ionic framework binding(s) a project depends on and the major * version installed for each, by reading its `package.json`. @@ -55,10 +74,7 @@ export function detectFrameworks(ctx: MigrationContext): DetectedFramework[] { const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const detected: DetectedFramework[] = []; - for (const [framework, pkgName] of Object.entries(FRAMEWORK_PACKAGES) as [ - Exclude, - string, - ][]) { + for (const [framework, pkgName] of Object.entries(FRAMEWORK_PACKAGES) as [Framework, string][]) { const range = deps[pkgName]; if (range === undefined) continue; // A project already pinned to the v9 dev build reads as major 8 via semver diff --git a/packages/migrate/src/main.ts b/packages/migrate/src/main.ts index 75e8c1c54c6..48557f65556 100644 --- a/packages/migrate/src/main.ts +++ b/packages/migrate/src/main.ts @@ -2,7 +2,7 @@ import { execSync } from 'node:child_process'; import { resolve } from 'node:path'; import { createDiskContext } from './context.js'; -import { detectFrameworks } from './detect.js'; +import { detectFrameworks, sourceMajor } from './detect.js'; import { resolveTarget, selectMigrations } from './registry.js'; import { run } from './runner.js'; import { buildReport } from './report.js'; @@ -116,11 +116,11 @@ export function main(argv: string[]): number { const detected = detectFrameworks(ctx); if (detected.length === 0) { - console.log(`No @ionic/{angular,react,vue} dependency found in ${rootDir}. Nothing to do.`); + console.log(`No @ionic/{angular,react,vue,core} dependency found in ${rootDir}. Nothing to do.`); return 0; } - const detectedMajor = Math.min(...detected.map((d) => d.major)); + const detectedMajor = sourceMajor(detected)!; // A `--from` below the detected major re-selects migrations the project has // already had applied. Some (e.g. angular-standalone-imports) are single-shot // and corrupt already-migrated code if re-run, so require --force to override diff --git a/packages/migrate/src/migrations/index.ts b/packages/migrate/src/migrations/index.ts index 2ea57516cc6..5cd3f0ce7a3 100644 --- a/packages/migrate/src/migrations/index.ts +++ b/packages/migrate/src/migrations/index.ts @@ -5,11 +5,25 @@ import { angularCssTilde } from './v9/angular-css-tilde.js'; import { angularZoneless } from './v9/angular-zoneless.js'; import { angularZonelessManual } from './v9/angular-zoneless-manual.js'; import { angularDeps } from './v9/angular-deps.js'; +import { angularIonicModule } from './v9/angular-ionic-module.js'; +import { angularModuleResolution } from './v9/angular-module-resolution.js'; +import { angularTypescript } from './v9/angular-typescript.js'; +import { angularVersion } from './v9/angular-version.js'; import { reactDeps } from './v9/react-deps.js'; import { reactRouter6Routes } from './v9/react-router-6-routes.js'; import { reactRouter6Code } from './v9/react-router-6-code.js'; import { vueDeps } from './v9/vue-deps.js'; import { vueRouterNextGuard } from './v9/vue-router-next-guard.js'; +import { coreBrowserslist } from './v9/core-browserslist.js'; +import { coreCapacitor } from './v9/core-capacitor.js'; +import { coreDeps } from './v9/core-deps.js'; +import { coreFloatingLabel } from './v9/core-floating-label.js'; +import { coreFormStructure } from './v9/core-form-structure.js'; +import { coreModalHandle } from './v9/core-modal-handle.js'; +import { corePackageExports } from './v9/core-package-exports.js'; +import { coreSelectEvents } from './v9/core-select-events.js'; +import { coreSwipeBackConfig } from './v9/core-swipe-back-config.js'; +import { coreTextareaHeight } from './v9/core-textarea-height.js'; import { coreLegacyPicker } from './v9/core-legacy-picker.js'; import { coreAutocorrect } from './v9/core-autocorrect.js'; import { coreAutocorrectManual } from './v9/core-autocorrect-manual.js'; @@ -27,11 +41,25 @@ export const allMigrations: Migration[] = [ angularZoneless, angularZonelessManual, angularDeps, + angularIonicModule, + angularModuleResolution, + angularTypescript, + angularVersion, reactDeps, reactRouter6Routes, reactRouter6Code, vueDeps, vueRouterNextGuard, + coreBrowserslist, + coreCapacitor, + coreDeps, + coreFloatingLabel, + coreFormStructure, + coreModalHandle, + corePackageExports, + coreSelectEvents, + coreSwipeBackConfig, + coreTextareaHeight, coreLegacyPicker, coreAutocorrect, coreAutocorrectManual, diff --git a/packages/migrate/src/migrations/v9/angular-css-tilde.ts b/packages/migrate/src/migrations/v9/angular-css-tilde.ts index 25e4c43f637..1294398e0f2 100644 --- a/packages/migrate/src/migrations/v9/angular-css-tilde.ts +++ b/packages/migrate/src/migrations/v9/angular-css-tilde.ts @@ -1,4 +1,5 @@ import type { Finding, Migration } from '../../types.js'; +import { STYLE_GLOBS } from '../../ast/text-scan.js'; /** * Angular's build pipeline no longer supports the webpack-loader `~` prefix when @@ -23,7 +24,7 @@ export const angularCssTilde: Migration = { detect(ctx) { const findings: Finding[] = []; - for (const filePath of ctx.glob(['**/*.css', '**/*.scss'])) { + for (const filePath of ctx.glob(STYLE_GLOBS)) { const text = ctx.readFile(filePath); if (text === undefined) continue; text.split('\n').forEach((line, i) => { @@ -39,7 +40,7 @@ export const angularCssTilde: Migration = { }, fix(ctx) { - for (const filePath of ctx.glob(['**/*.css', '**/*.scss'])) { + for (const filePath of ctx.glob(STYLE_GLOBS)) { const text = ctx.readFile(filePath); if (text === undefined) continue; const next = text.replace(TILDE_IONIC_IMPORT, '$1$2'); diff --git a/packages/migrate/src/migrations/v9/angular-ionic-module.ts b/packages/migrate/src/migrations/v9/angular-ionic-module.ts new file mode 100644 index 00000000000..64aec06d3d1 --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-ionic-module.ts @@ -0,0 +1,41 @@ +import type { Finding, Migration } from '../../types.js'; + +/** + * `IonicModule` is deprecated in Ionic 9 in favor of `provideIonicAngular()`, + * which works in both standalone and NgModule apps. Report-only: it still + * works, and the rewrite is structural (the entry moves from a module's + * `imports` to its `providers`, with any `forRoot()` config). + * + * See https://ionicframework.com/docs/updating/9-0#ionicmodule-deprecation + */ +// Word-bounded so an app's own `MyIonicModuleHelper` is not mistaken for it. +const IONIC_MODULE = /\bIonicModule\b/; + +export const angularIonicModule: Migration = { + id: 'angular-ionic-module', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#ionicmodule-deprecation', + + detect(ctx) { + // One finding per file, at the first mention: a module names `IonicModule` + // on both its import line and in its `imports` array. + const findings: Finding[] = []; + for (const filePath of ctx.glob(['**/*.ts'])) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + const line = text.split('\n').findIndex((l) => IONIC_MODULE.test(l)); + if (line !== -1) { + findings.push({ + filePath, + line: line + 1, + detail: + 'IonicModule is deprecated. Migrate to provideIonicAngular(), which works in standalone and NgModule apps', + }); + } + } + return findings; + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-module-resolution.ts b/packages/migrate/src/migrations/v9/angular-module-resolution.ts new file mode 100644 index 00000000000..bc92a1f8ba6 --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-module-resolution.ts @@ -0,0 +1,75 @@ +import type { Finding, Migration } from '../../types.js'; + +/** + * `@ionic/angular` is now published with `exports`-based subpath resolution, so + * classic TypeScript module resolution can no longer resolve subpaths like + * `@ionic/angular/lazy`. + * + * The `angular-standalone-imports` migration introduces those subpath imports, + * so without this fix it can leave an app that no longer compiles. + * + * Edited as text rather than through `JSON.parse`: Angular's generated + * `tsconfig.json` carries `//` comments, which are valid JSONC but not JSON, and + * round-tripping through `JSON.parse`/`stringify` would silently drop them. + * + * See https://ionicframework.com/docs/updating/9-0#module-resolution + */ +/** A `"moduleResolution"` entry set to a classic, non-`exports`-aware value. */ +const CLASSIC_RESOLUTION = /("moduleResolution"\s*:\s*")(node|node10|classic)(")/gi; +/** + * TypeScript rejects `bundler` resolution unless `module` is `preserve` or an ES + * variant (TS5095). These are Node-side configs (Angular SSR, web workers) that + * never resolve `@ionic/angular` subpaths, so rewriting one would only break the + * build. + */ +const COMMONJS_MODULE = /"module"\s*:\s*"(commonjs|node16|nodenext)"/i; +const TSCONFIG_GLOBS = ['**/tsconfig*.json']; +const DETAIL = 'set moduleResolution to "bundler" so @ionic/angular subpaths resolve'; + +/** + * The rewritten line, or `undefined` when there is nothing to change. A match + * behind a `//` is left alone. Shared by detect/fix so the report and the edit + * can't disagree. + */ +function rewrite(line: string): string | undefined { + const match = line.search(new RegExp(CLASSIC_RESOLUTION.source, 'i')); + if (match === -1) return undefined; + const comment = line.indexOf('//'); + if (comment !== -1 && comment < match) return undefined; + return line.replace(new RegExp(CLASSIC_RESOLUTION.source, 'gi'), '$1bundler$3'); +} + +export const angularModuleResolution: Migration = { + id: 'angular-module-resolution', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#module-resolution', + + detect(ctx) { + const findings: Finding[] = []; + for (const filePath of ctx.glob(TSCONFIG_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined || COMMONJS_MODULE.test(text)) continue; + text.split('\n').forEach((line, i) => { + if (rewrite(line) !== undefined) { + findings.push({ filePath, line: i + 1, detail: DETAIL }); + } + }); + } + return findings; + }, + + fix(ctx) { + for (const filePath of ctx.glob(TSCONFIG_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined || COMMONJS_MODULE.test(text)) continue; + const next = text + .split('\n') + .map((line) => rewrite(line) ?? line) + .join('\n'); + if (next !== text) ctx.writeFile(filePath, next); + } + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-typescript.ts b/packages/migrate/src/migrations/v9/angular-typescript.ts new file mode 100644 index 00000000000..1f890a7b0c9 --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-typescript.ts @@ -0,0 +1,17 @@ +import { createDepsMigration } from '../../ast/deps-migration.js'; + +/** + * Ionic 9 requires TypeScript 5.4 or later, matching Angular 18's own minimum. + * A higher pin is left alone (Angular 21 wants 5.9, Angular 22 wants 6.0). + * + * Pinned with a tilde: Angular's peer ranges are narrow (18 accepts `>=5.4 <5.6`), + * and `^5.4.0` resolves to 5.9.x, which nothing below Angular 20 accepts. + * + * See https://ionicframework.com/docs/updating/9-0#typescript + */ +export const angularTypescript = createDepsMigration({ + id: 'angular-typescript', + framework: 'angular', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#typescript', + bumps: [['typescript', '~5.4.0']], +}); diff --git a/packages/migrate/src/migrations/v9/angular-version.ts b/packages/migrate/src/migrations/v9/angular-version.ts new file mode 100644 index 00000000000..889594b20ee --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-version.ts @@ -0,0 +1,77 @@ +import { isPlainSemverRange, parseMajor } from '../../detect.js'; +import { findDependency, readPackageJson } from '../../ast/package-json.js'; +import type { Finding, Migration } from '../../types.js'; +import { V9_DOCS } from './docs.js'; + +/** + * Report-only checks on the Angular toolchain around an Ionic 9 upgrade. Neither + * is ours to rewrite: `ng update` owns the Angular version, and + * `@ionic/angular-toolkit` versions on its own release line, so there is no + * major this tool could safely write for it. + * + * See https://ionicframework.com/docs/updating/9-0#angular + */ +/** The oldest Angular Ionic 9 supports. 16 and 17 were dropped. */ +const MIN_ANGULAR = 18; +/** The first Angular whose components default to `OnPush`. */ +const ONPUSH_ANGULAR = 22; +const ANGULAR_CORE = '@angular/core'; +const TOOLKIT = '@ionic/angular-toolkit'; + +export const angularVersion: Migration = { + id: 'angular-version', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#angular', + + detect(ctx) { + const parsed = readPackageJson(ctx); + if (!parsed) return []; + + const findings: Finding[] = []; + const at = (detail: string, docsUrl: string): Finding => ({ + filePath: 'package.json', + line: 1, + detail, + docsUrl, + }); + + const angular = findDependency(parsed.pkg, ANGULAR_CORE); + // A protocol/alias range carries no comparable version to check. + if (angular && isPlainSemverRange(angular.range)) { + const major = parseMajor(angular.range); + if (major !== undefined && major < MIN_ANGULAR) { + findings.push( + at( + `Angular ${major} is not supported by Ionic 9. Update to Angular ${MIN_ANGULAR} or later with \`ng update\``, + `${V9_DOCS}#angular` + ) + ); + } + if (major !== undefined && major >= ONPUSH_ANGULAR) { + findings.push( + at( + `Angular ${major} defaults components to OnPush. State set as a plain field in an Ionic lifecycle hook no longer re-renders`, + `${V9_DOCS}#onpush-change-detection-on-angular-22` + ) + ); + findings.push( + at( + `Angular ${major} raises the minimum Node.js version. Check your CI and local runtime`, + `${V9_DOCS}#nodejs` + ) + ); + } + } + + if (findDependency(parsed.pkg, TOOLKIT)) { + findings.push( + at(`update ${TOOLKIT} to its latest release. It versions separately from @ionic/angular`, `${V9_DOCS}#angular`) + ); + } + + return findings; + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-autocorrect.ts b/packages/migrate/src/migrations/v9/core-autocorrect.ts index 4fd060128e3..65c7a14b850 100644 --- a/packages/migrate/src/migrations/v9/core-autocorrect.ts +++ b/packages/migrate/src/migrations/v9/core-autocorrect.ts @@ -31,7 +31,9 @@ import { V9_DOCS } from './docs.js'; * * See https://ionicframework.com/docs/updating/9-0#input */ -const TEMPLATE_GLOBS = ['**/*.html', '**/*.vue']; +// Not the shared `TEMPLATE_GLOBS`: that includes `.tsx`, which +// `jsxAutocorrectAttrs` already covers, so scanning it here would double-report. +const NON_JSX_TEMPLATE_GLOBS = ['**/*.html', '**/*.vue']; const TEMPLATE_TAGS = ['ion-input', 'ion-searchbar']; const JSX_TAGS = new Set(['IonInput', 'IonSearchbar']); export const OFF_ATTR = /\s+autocorrect\s*=\s*["']off["']/; @@ -98,7 +100,7 @@ export const coreAutocorrect: Migration = { const findings: Finding[] = []; const isAngular = detectFrameworks(ctx).some((f) => f.framework === 'angular'); - for (const filePath of ctx.glob(TEMPLATE_GLOBS)) { + for (const filePath of ctx.glob(NON_JSX_TEMPLATE_GLOBS)) { const text = ctx.readFile(filePath); if (text === undefined) continue; // `on` is only convertible where the binding dialect is known: Vue SFCs, @@ -129,7 +131,7 @@ export const coreAutocorrect: Migration = { fix(ctx) { const isAngular = detectFrameworks(ctx).some((f) => f.framework === 'angular'); - for (const filePath of ctx.glob(TEMPLATE_GLOBS)) { + for (const filePath of ctx.glob(NON_JSX_TEMPLATE_GLOBS)) { const text = ctx.readFile(filePath); if (text === undefined) continue; // Vue binds with `:attr`, Angular with `[attr]`. Anything else (a vanilla diff --git a/packages/migrate/src/migrations/v9/core-browserslist.ts b/packages/migrate/src/migrations/v9/core-browserslist.ts new file mode 100644 index 00000000000..9ddb7e4dd5d --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-browserslist.ts @@ -0,0 +1,87 @@ +import type { Finding, Migration } from '../../types.js'; + +/** + * Ionic 9 raises its supported browser floors. The guide prints a replacement + * `.browserslistrc` block, but this raises the version numbers in place: + * overwriting the file would drop comments and queries unrelated to Ionic. + * + * Only entries this owns are touched - a named browser with a `>=` floor below + * Ionic 9's. + * + * See https://ionicframework.com/docs/updating/9-0#browser-support + */ +/** Minimum version Ionic 9 supports, by browserslist browser name. */ +const FLOORS: Record = { + chrome: 89, + chromeandroid: 89, + firefox: 75, + edge: 89, + safari: 16, + ios: 16, +}; + +/** + * A `Name >=Version` entry, the shape the Ionic starters generate. The version + * is captured whole so raising `Safari >=15.4` writes `>=16`, not `>=16.4`. The + * optional `\r` keeps a CRLF checkout from matching nothing. + */ +const ENTRY = /^(\s*)([A-Za-z_]+)(\s*>=\s*)(\d+(?:\.\d+)*)(.*?)\r?$/; + +const BROWSERSLIST_GLOBS = ['**/.browserslistrc', '**/browserslist']; + +/** + * The raised version of a browserslist line, or `undefined` when the line is not + * an entry this owns or is already at or above the floor. Shared by detect/fix + * so the report and the edit can never disagree. + */ +function raise(line: string): { name: string; from: string; to: number; line: string } | undefined { + const m = ENTRY.exec(line); + if (!m) return undefined; + const [, indent, name, op, version, rest] = m; + const floor = FLOORS[name.toLowerCase()]; + if (floor === undefined) return undefined; + // Compare on the major alone, so `Safari >=16.3` counts as meeting a floor of 16. + if (Number.parseInt(version, 10) >= floor) return undefined; + const crlf = line.endsWith('\r') ? '\r' : ''; + return { name, from: version, to: floor, line: `${indent}${name}${op}${floor}${rest}${crlf}` }; +} + +export const coreBrowserslist: Migration = { + id: 'core-browserslist', + framework: 'core', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const findings: Finding[] = []; + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + text.split('\n').forEach((line, i) => { + const raised = raise(line); + if (raised) { + findings.push({ + filePath, + line: i + 1, + detail: `${raised.name} >=${raised.from} is below Ionic 9's floor. Raise it to >=${raised.to}`, + }); + } + }); + } + return findings; + }, + + fix(ctx) { + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + const next = text + .split('\n') + .map((line) => raise(line)?.line ?? line) + .join('\n'); + if (next !== text) ctx.writeFile(filePath, next); + } + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-capacitor.ts b/packages/migrate/src/migrations/v9/core-capacitor.ts new file mode 100644 index 00000000000..aad1f2ca9d4 --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-capacitor.ts @@ -0,0 +1,44 @@ +import { isPlainSemverRange, parseMajor } from '../../detect.js'; +import { findDependency, readPackageJson } from '../../ast/package-json.js'; +import type { Migration } from '../../types.js'; + +/** + * Ionic 9's `isCapacitorNative` relies solely on `Capacitor.isNativePlatform()`, + * added in Capacitor 3. Report-only: the fix is a Capacitor upgrade. + * + * See https://ionicframework.com/docs/updating/9-0#capacitor + */ +/** The first Capacitor with `isNativePlatform()`. */ +const MIN_CAPACITOR = 3; +const CAPACITOR_CORE = '@capacitor/core'; + +export const coreCapacitor: Migration = { + id: 'core-capacitor', + framework: 'core', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#capacitor', + + detect(ctx) { + const parsed = readPackageJson(ctx); + if (!parsed) return []; + + const dep = findDependency(parsed.pkg, CAPACITOR_CORE); + // A protocol/alias range carries no comparable version to check. + if (!dep || !isPlainSemverRange(dep.range)) return []; + + const major = parseMajor(dep.range); + if (major === undefined || major >= MIN_CAPACITOR) return []; + + return [ + { + filePath: 'package.json', + line: 1, + detail: + `Capacitor ${major} is no longer detected as a native platform. ` + + `isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web. Upgrade to Capacitor 7 or later`, + }, + ]; + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-deps.ts b/packages/migrate/src/migrations/v9/core-deps.ts new file mode 100644 index 00000000000..e5a8f830555 --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-deps.ts @@ -0,0 +1,18 @@ +import { createDepsMigration } from '../../ast/deps-migration.js'; +import { IONIC_V9_VERSION } from '../../versions.js'; + +/** + * Raise a directly declared `@ionic/core` to v9, for a vanilla app and for a + * framework app that pins `@ionic/core` itself and would otherwise resolve the + * v8 build next to a v9 binding. Nothing happens when `@ionic/core` is only a + * transitive dependency, since `createDepsMigration` skips packages the + * manifest does not declare. + * + * See https://ionicframework.com/docs/updating/9-0#core + */ +export const coreDeps = createDepsMigration({ + id: 'core-deps', + framework: 'core', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#core', + bumps: [['@ionic/core', IONIC_V9_VERSION]], +}); diff --git a/packages/migrate/src/migrations/v9/core-floating-label.ts b/packages/migrate/src/migrations/v9/core-floating-label.ts new file mode 100644 index 00000000000..7d22781ab5a --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-floating-label.ts @@ -0,0 +1,86 @@ +import type { Finding, Migration } from '../../types.js'; +import { findOpeningTags, lineAt } from '../../ast/markup.js'; +import { TEMPLATE_GLOBS } from '../../ast/text-scan.js'; +import { V9_DOCS } from './docs.js'; + +/** + * A floating label on `ion-input`, `ion-textarea`, and `ion-select` no longer + * floats just because the control has slotted content. It floats on focus or a + * value. + * + * Only reported when the element has slotted content: the change is + * invisible without it, and floating labels are common enough that flagging + * every one would be noise. + * + * Report-only - whether the old look mattered is a design call. + * + * See https://ionicframework.com/docs/updating/9-0#input-floating-label-behavior + */ +/** + * A floating `labelPlacement`, in every dialect: the plain attribute, an Angular + * or Vue binding to a quoted string, and the JSX camelCase prop. + */ +const FLOATING = /label-?placement[^=]{0,3}=\s*["']['"]?\s*floating/i; +/** Slotted start/end content inside the element body. */ +const SLOTTED = /slot\s*=\s*["'](start|end)["']/; + +/** Lower-cased tag name (both spellings) -> the docs anchor for that component. */ +const COMPONENTS: Record = { + 'ion-input': `${V9_DOCS}#input-floating-label-behavior`, + ioninput: `${V9_DOCS}#input-floating-label-behavior`, + 'ion-textarea': `${V9_DOCS}#textarea-floating-label-behavior`, + iontextarea: `${V9_DOCS}#textarea-floating-label-behavior`, + 'ion-select': `${V9_DOCS}#select-floating-label-behavior`, + ionselect: `${V9_DOCS}#select-floating-label-behavior`, +}; + +/** A `placeholder`, in any dialect (`placeholder=`, `[placeholder]=`, `:placeholder=`). */ +const PLACEHOLDER = /\bplaceholder\b[^=]{0,3}=/i; +const DETAIL = + 'floating label with slotted content no longer floats by default. It floats on focus or a value'; +const PLACEHOLDER_DETAIL = + 'with a floating label, the select placeholder is only visible while the select is focused'; + +/** + * The text between an opening tag and its closing tag, or `undefined` for a + * self-closing element or when no closing tag is found. + */ +function elementBody(source: string, tag: { name: string; end: number; text: string }): string | undefined { + if (tag.text.endsWith('/>')) return undefined; + const close = source.toLowerCase().indexOf(` { + const rule = RULES.find((r) => r.pattern.test(line)); + return rule && { detail: rule.detail, docsUrl: rule.docsUrl }; + }); + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-modal-handle.ts b/packages/migrate/src/migrations/v9/core-modal-handle.ts new file mode 100644 index 00000000000..67ecd3b376a --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-modal-handle.ts @@ -0,0 +1,99 @@ +import type { Finding, Migration } from '../../types.js'; +import { findOpeningTags, lineAt } from '../../ast/markup.js'; +import { TEMPLATE_GLOBS } from '../../ast/text-scan.js'; + +/** + * `handleBehavior` on `ion-modal` now defaults to `"cycle"` instead of + * `"none"`. A sheet modal's handle becomes focusable, and activating it cycles + * the sheet through its breakpoints. + * + * Scoped to sheet modals: without `breakpoints`/`initialBreakpoint` there is no + * handle for the new default to affect. A modal that already sets + * `handleBehavior` is left alone. + * + * Report-only: whether the old inert handle was intentional is the app's call. + * + * See https://ionicframework.com/docs/updating/9-0#modal + */ +const MODAL_TAGS = ['ion-modal', 'IonModal']; +/** A sheet modal declares breakpoints, in any binding dialect. */ +const SHEET = /\b(breakpoints|initial-?breakpoint)\b/i; +/** An explicit opt-in or opt-out, so the new default is not a surprise. */ +const HAS_BEHAVIOR = /\bhandle-?behavior\b/i; +/** A breakpoint in a controller options object, as opposed to a JSX prop. */ +const OPTIONS_BREAKPOINT = /\binitialBreakpoint\s*:/g; + +/** + * The options object containing the breakpoint at `index`, found by walking out + * to its braces. Scoping the `handleBehavior` check here keeps one inert sheet + * from silencing the others beside it, and keeps a `.vue` template opt-out out + * of its script block. + */ +function enclosingObject(text: string, index: number): string { + let depth = 0; + let start = 0; + for (let i = index; i >= 0; i--) { + if (text[i] === '}') depth++; + else if (text[i] === '{') { + if (depth === 0) { + start = i; + break; + } + depth--; + } + } + depth = 0; + let end = text.length; + for (let i = start + 1; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + if (depth === 0) { + end = i; + break; + } + depth--; + } + } + return text.slice(start, end); +} +// `.vue` included for a `\n`, + }); + + expect(coreModalHandle.detect(ctx)).toHaveLength(1); + }); + + it('stays quiet on a modal that is not a sheet', () => { + const ctx = createInMemoryContext({ 'a.html': `\n` }); + + expect(coreModalHandle.detect(ctx)).toEqual([]); + }); + + it('flags a sheet built through the modal controller', () => { + const ctx = createInMemoryContext({ + 'page.ts': `const modal = await modalController.create({ component: Page, initialBreakpoint: 0.5 });\n`, + 'Page.tsx': `const [present] = useIonModal(Body, { initialBreakpoint: 0.5 });\n`, + }); + + expect(coreModalHandle.detect(ctx)).toHaveLength(2); + }); + + it('flags a sheet created from a Vue script block', () => { + const ctx = createInMemoryContext({ + 'Page.vue': `\n`, + }); + + expect(coreModalHandle.detect(ctx)).toHaveLength(1); + }); + + it('reports a JSX sheet once, not twice for the same element', () => { + // The element scan already covers .tsx, so the options-object scan must not + // match a JSX prop as well. + const ctx = createInMemoryContext({ + 'Page.tsx': `const m = ;\n`, + }); + + expect(coreModalHandle.detect(ctx)).toHaveLength(1); + }); +}); + +describe('core-select-events', () => { + it('flags an ionChange handler on a select, which no longer fires on every confirm', () => { + const ctx = createInMemoryContext({ + 'home.page.html': `\n`, + }); + + const findings = coreSelectEvents.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#ionchange-only-fires-when-the-value-changes`); + }); + + it('reads the React and Vue spellings of the handler', () => { + const ctx = createInMemoryContext({ + 'App.tsx': `const a = ;\n`, + 'Page.vue': `\n`, + }); + + expect(coreSelectEvents.detect(ctx)).toHaveLength(2); + }); + + it('reads a JavaScript React app, which has no ts-morph coverage', () => { + const ctx = createInMemoryContext({ + 'App.jsx': `const a = ;\n`, + }); + + expect(coreSelectEvents.detect(ctx)).toHaveLength(1); + }); + + it('does not flag ionChange on other components', () => { + const ctx = createInMemoryContext({ + 'home.page.html': `\n`, + }); + + expect(coreSelectEvents.detect(ctx)).toEqual([]); + }); + + it('flags reading the action sheet dismiss role, which no longer says selected', () => { + const ctx = createInMemoryContext({ + 'page.ts': + `const sel = document.querySelector('ion-select');\n` + + `el.addEventListener('ionActionSheetDidDismiss', (e) => console.log(e.detail.role));\n`, + }); + + const findings = coreSelectEvents.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#action-sheet-interface-selected-role-removed`); + }); + + it('leaves an app\'s own action sheet alone, which the change never touched', () => { + const ctx = createInMemoryContext({ + 'page.ts': + `const sheet = await actionSheetController.create({ buttons });\n` + + `sheet.addEventListener('ionActionSheetDidDismiss', (e) => console.log(e.detail.role));\n`, + }); + + expect(coreSelectEvents.detect(ctx)).toEqual([]); + }); +}); + +describe('core-swipe-back-config', () => { + it('flags the swipeBackEnabled config, now read once at outlet mount', () => { + const ctx = createInMemoryContext({ + 'main.tsx': `setupIonicReact({ swipeBackEnabled: someCondition });\n`, + }); + + const findings = coreSwipeBackConfig.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('swipeGesture'); + }); + + it('stays quiet when the config is not used', () => { + const ctx = createInMemoryContext({ 'main.tsx': `setupIonicReact({ mode: 'ios' });\n` }); + + expect(coreSwipeBackConfig.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-browserslist.test.ts b/packages/migrate/test/core-browserslist.test.ts new file mode 100644 index 00000000000..a4dcad35406 --- /dev/null +++ b/packages/migrate/test/core-browserslist.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreBrowserslist as migration } from '../src/migrations/v9/core-browserslist.js'; + +describe('core-browserslist', () => { + it('raises a below-floor browser version to the one Ionic 9 supports', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `Chrome >=79\n` }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('raises every stale entry of an Ionic 8 starter browserslist', () => { + const ctx = createInMemoryContext({ + '.browserslistrc': + `Chrome >=79\nChromeAndroid >=79\nFirefox >=70\nEdge >=79\nSafari >=14\niOS >=14\n`, + }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe( + `Chrome >=89\nChromeAndroid >=89\nFirefox >=75\nEdge >=89\nSafari >=16\niOS >=16\n` + ); + }); + + it('leaves comments, blank lines, and queries it does not own untouched', () => { + // Overwriting the file would discard the project's own entries. + const ctx = createInMemoryContext({ + '.browserslistrc': `# Supported browsers\n\nChrome >=79\nnot dead\nlast 2 versions\n`, + }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe( + `# Supported browsers\n\nChrome >=89\nnot dead\nlast 2 versions\n` + ); + }); + + it('replaces the whole version, not just its major', () => { + // `Safari >=15.4` must become `>=16`, not `>=16.4`. + const ctx = createInMemoryContext({ '.browserslistrc': `Safari >=15.4\nChrome >=88.0.4324\n` }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe(`Safari >=16\nChrome >=89\n`); + }); + + it('reads a file with CRLF line endings', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `Chrome >=79\r\nSafari >=14\r\n` }); + + expect(migration.detect(ctx)).toHaveLength(2); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\r\nSafari >=16\r\n`); + }); + + it('does not lower a browser already above the floor', () => { + const source = `Chrome >=100\nSafari >=17\n`; + const ctx = createInMemoryContext({ '.browserslistrc': source }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(source); + }); + + it('reports the file and line of each stale entry', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `# browsers\nChrome >=79\nSafari >=14\n` }); + + expect(migration.detect(ctx).map((f) => `${f.filePath}:${f.line}`)).toEqual([ + '.browserslistrc:2', + '.browserslistrc:3', + ]); + }); +}); diff --git a/packages/migrate/test/core-capacitor.test.ts b/packages/migrate/test/core-capacitor.test.ts new file mode 100644 index 00000000000..96d1f849154 --- /dev/null +++ b/packages/migrate/test/core-capacitor.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreCapacitor } from '../src/migrations/v9/core-capacitor.js'; + +describe('core-capacitor', () => { + it('flags Capacitor 2, which no longer reports as a native platform', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^2.4.0' } }, null, 2), + }); + + const findings = coreCapacitor.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('Capacitor 2'); + }); + + it('says nothing about a supported Capacitor', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^7.0.0' } }, null, 2), + }); + + expect(coreCapacitor.detect(ctx)).toEqual([]); + }); + + it('says nothing when the app does not use Capacitor at all', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@ionic/core': '^8.0.0' } }, null, 2), + }); + + expect(coreCapacitor.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-floating-label.test.ts b/packages/migrate/test/core-floating-label.test.ts new file mode 100644 index 00000000000..1d424e65d71 --- /dev/null +++ b/packages/migrate/test/core-floating-label.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreFloatingLabel as migration } from '../src/migrations/v9/core-floating-label.js'; +import { V9_DOCS } from '../src/migrations/v9/docs.js'; + +describe('core-floating-label', () => { + it('flags a floating label on an input that has slotted content', () => { + const ctx = createInMemoryContext({ + 'home.page.html': + `\n` + + ` \n` + + `\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#input-floating-label-behavior`); + }); + + it('stays quiet on a floating label with no slotted content', () => { + const ctx = createInMemoryContext({ + 'home.page.html': `\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('stays quiet on slotted content with a non-floating label', () => { + const ctx = createInMemoryContext({ + 'home.page.html': + `\n` + + ` \n` + + `\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('links each component to its own section of the guide', () => { + const ctx = createInMemoryContext({ + 'page.html': + `x\n` + + `x\n`, + }); + + expect(migration.detect(ctx).map((f) => f.docsUrl)).toEqual([ + `${V9_DOCS}#textarea-floating-label-behavior`, + `${V9_DOCS}#select-floating-label-behavior`, + ]); + }); + + it('flags a floating select placeholder, which now shows only on focus', () => { + // The guide adds this to the select section without the slotted-content + // condition, so the slot gate would miss it entirely. + const ctx = createInMemoryContext({ + 'page.html': `\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('placeholder'); + }); + + it('reads a Vue binding and a React prop, not just the plain attribute', () => { + const ctx = createInMemoryContext({ + 'Page.vue': `\n`, + 'App.tsx': `const a = ;\n`, + }); + + expect(migration.detect(ctx)).toHaveLength(2); + }); +}); diff --git a/packages/migrate/test/core-form-structure.test.ts b/packages/migrate/test/core-form-structure.test.ts new file mode 100644 index 00000000000..42df6fd3587 --- /dev/null +++ b/packages/migrate/test/core-form-structure.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreFormStructure as migration } from '../src/migrations/v9/core-form-structure.js'; +import { V9_DOCS } from '../src/migrations/v9/docs.js'; + +describe('core-form-structure', () => { + it('flags a textarea selector whose wrapper no longer exists', () => { + const ctx = createInMemoryContext({ + 'styles.scss': `ion-textarea .textarea-wrapper-inner .native-wrapper {\n color: red;\n}\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('.textarea-control'); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#textarea-internal-dom-structure-changes`); + }); + + it('flags the renamed textarea slot wrappers', () => { + const ctx = createInMemoryContext({ + 'styles.css': `.start-slot-wrapper { margin: 0; }\n.end-slot-wrapper { margin: 0; }\n`, + }); + + const details = migration.detect(ctx).map((f) => f.detail); + + expect(details).toHaveLength(2); + expect(details[0]).toContain('.textarea-start'); + expect(details[1]).toContain('.textarea-end'); + }); + + it('flags the select part that was removed outright', () => { + const ctx = createInMemoryContext({ + 'styles.css': `ion-select::part(inner) {\n padding: 0;\n}\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('removed'); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#select-internal-dom-structure-changes`); + }); + + it('flags an input selector that relies on the old wrapper nesting', () => { + const ctx = createInMemoryContext({ + 'styles.css': `ion-input .input-wrapper .native-wrapper { border: 0; }\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('.input-control'); + expect(findings[0].docsUrl).toBe(`${V9_DOCS}#input-internal-dom-structure-changes`); + }); + + it('gives the slot answer for the guide\'s own example selector', () => { + // The guide maps this exact selector to `.input-start [slot="start"]`. The + // broader wrapper rule also matches it, so the slot rules have to win or + // the developer is sent to `.input-control`, which still won't match. + const ctx = createInMemoryContext({ + 'styles.css': `ion-input .input-wrapper .native-wrapper [slot="start"] { color: red; }\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('.input-start'); + }); + + it('flags slotted content that moved out of the native wrapper', () => { + const ctx = createInMemoryContext({ + 'styles.css': `ion-input .native-wrapper [slot="start"] { color: red; }\n`, + }); + + expect(migration.detect(ctx)).toHaveLength(1); + }); + + it('flags the label wrapper, which moved on all three components', () => { + const ctx = createInMemoryContext({ + 'styles.css': `ion-input .input-wrapper .label-text-wrapper { color: red; }\n`, + }); + + expect(migration.detect(ctx)).toHaveLength(1); + }); + + it('leaves class names that still exist in v9 alone', () => { + const ctx = createInMemoryContext({ + 'styles.css': `ion-input .native-wrapper { color: red; }\nion-input .input-wrapper { color: blue; }\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-package-exports.test.ts b/packages/migrate/test/core-package-exports.test.ts new file mode 100644 index 00000000000..3aaf947c5b5 --- /dev/null +++ b/packages/migrate/test/core-package-exports.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { corePackageExports } from '../src/migrations/v9/core-package-exports.js'; + +describe('core-package-exports', () => { + it('flags an import from a path the exports field does not expose', () => { + const ctx = createInMemoryContext({ + 'app.ts': `import { Foo } from '@ionic/core/internal/utils';\n`, + }); + + const findings = corePackageExports.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('@ionic/core/internal/utils'); + }); + + it('allows every key the exports map declares, wildcards included', () => { + // Mirrors core/package.json, which is broader than the guide's table. + const ctx = createInMemoryContext({ + 'app.ts': + `import { modalController } from '@ionic/core';\n` + + `import { IonButton } from '@ionic/core/components';\n` + + `import { defineCustomElement } from '@ionic/core/components/ion-button.js';\n` + + `import { defineCustomElements } from '@ionic/core/loader';\n` + + `import { x } from '@ionic/core/loader/index.js';\n` + + `import { y } from '@ionic/core/hydrate/index.mjs';\n` + + `import { z } from '@ionic/core/dist/types/components';\n` + + `import pkg from '@ionic/core/package.json';\n` + + `import '@ionic/core/css/core.css';\n`, + }); + + expect(corePackageExports.detect(ctx)).toEqual([]); + }); + + it('ignores imports from other packages that merely start the same way', () => { + const ctx = createInMemoryContext({ + 'app.ts': `import { x } from '@ionic/core-utils/deep/path';\n`, + }); + + expect(corePackageExports.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-textarea-height.test.ts b/packages/migrate/test/core-textarea-height.test.ts new file mode 100644 index 00000000000..7aad993ab95 --- /dev/null +++ b/packages/migrate/test/core-textarea-height.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreTextareaHeight } from '../src/migrations/v9/core-textarea-height.js'; +import { V9_DOCS } from '../src/migrations/v9/docs.js'; + +describe('core-textarea-height', () => { + it('flags styling built on the old Material Design textarea height', () => { + const ctx = createInMemoryContext({ + 'styles.scss': `ion-textarea {\n min-height: 56px;\n}\n`, + }); + + const findings = coreTextareaHeight.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('72px'); + // One breaking change, so the link lives on the migration, not each finding. + expect(coreTextareaHeight.docsUrl).toBe(`${V9_DOCS}#minimum-height-change`); + }); + + it('does not flag a 56px value in a file with no textarea styling', () => { + const ctx = createInMemoryContext({ 'styles.css': `ion-button { height: 56px; }\n` }); + + expect(coreTextareaHeight.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/engine.test.ts b/packages/migrate/test/engine.test.ts index dd776023c8c..5df94363726 100644 --- a/packages/migrate/test/engine.test.ts +++ b/packages/migrate/test/engine.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createInMemoryContext } from '../src/context.js'; -import { detectFrameworks, parseMajor } from '../src/detect.js'; +import { detectFrameworks, parseMajor, sourceMajor } from '../src/detect.js'; import { IONIC_V9_VERSION } from '../src/versions.js'; import { resolveTarget, selectMigrations } from '../src/registry.js'; import { allMigrations } from '../src/migrations/index.js'; @@ -45,6 +45,33 @@ describe('detectFrameworks', () => { ]); }); + it('detects a vanilla @ionic/core app, which has no framework binding', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@ionic/core': '^8.4.1' } }), + }); + + expect(detectFrameworks(ctx)).toEqual([{ framework: 'core', major: 8 }]); + }); + + it('gates on the binding, not a stale @ionic/core pin beside it', () => { + // Taking the lowest major would re-select the single-shot migrations. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ + dependencies: { '@ionic/angular': '^9.0.0', '@ionic/core': '^8.4.1' }, + }), + }); + + expect(sourceMajor(detectFrameworks(ctx))).toBe(9); + }); + + it('falls back to @ionic/core for a vanilla app with no binding', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@ionic/core': '^8.4.1' } }), + }); + + expect(sourceMajor(detectFrameworks(ctx))).toBe(8); + }); + it('parses the major from assorted range syntaxes', () => { expect(parseMajor('^8.4.1')).toBe(8); expect(parseMajor('9.0.0-rc.1')).toBe(9); @@ -136,6 +163,21 @@ describe('the shipped registry', () => { // bare numbers, so nothing else enforces it. expect(allMigrations.every((m) => m.toMajor > m.fromMajor)).toBe(true); }); + + it('gives every migration a unique id', () => { + // A duplicate id makes a finding ambiguous in the report. + const ids = allMigrations.map((m) => m.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('points every migration at a section of the upgrade guide', () => { + for (const migration of allMigrations) { + expect(migration.docsUrl, migration.id).toMatch( + /^https:\/\/ionicframework\.com\/docs\/updating\/9-0#[\w-]+$/ + ); + } + }); }); describe('resolveTarget', () => { diff --git a/packages/migrate/test/migrations.test.ts b/packages/migrate/test/migrations.test.ts index dad0c078377..306663f6873 100644 --- a/packages/migrate/test/migrations.test.ts +++ b/packages/migrate/test/migrations.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { createInMemoryContext } from '../src/context.js'; import { IONIC_V9_VERSION } from '../src/versions.js'; import { angularDeps } from '../src/migrations/v9/angular-deps.js'; +import { coreDeps } from '../src/migrations/v9/core-deps.js'; import { reactDeps } from '../src/migrations/v9/react-deps.js'; import { reactRouter6Code } from '../src/migrations/v9/react-router-6-code.js'; import { V9_DOCS } from '../src/migrations/v9/docs.js'; @@ -39,6 +40,55 @@ describe('react-deps', () => { expect(pkg.devDependencies['@types/react-router-dom']).toBeUndefined(); }); + it('raises React itself to the 18 floor Ionic 9 requires', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify( + { dependencies: { '@ionic/react': '^8.4.0', react: '^17.0.2', 'react-dom': '^17.0.2' } }, + null, + 2 + ), + }); + + reactDeps.fix!(ctx); + const pkg = JSON.parse(ctx.readFile('package.json')!); + + expect(pkg.dependencies['react']).toBe('^18.0.0'); + expect(pkg.dependencies['react-dom']).toBe('^18.0.0'); + }); + + it('carries the React types along with the runtime bump', () => { + // Bumping react without its @types leaves React 18 against React 17 types, + // and the run reinstalls straight after, so the app stops type-checking. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify( + { + dependencies: { '@ionic/react': '^8.4.0', react: '^17.0.2' }, + devDependencies: { '@types/react': '^17.0.39', '@types/react-dom': '^17.0.11' }, + }, + null, + 2 + ), + }); + + reactDeps.fix!(ctx); + const pkg = JSON.parse(ctx.readFile('package.json')!); + + expect(pkg.devDependencies['@types/react']).toBe('^18.0.0'); + expect(pkg.devDependencies['@types/react-dom']).toBe('^18.0.0'); + }); + + it('does not downgrade a React already above the floor', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify( + { dependencies: { '@ionic/react': IONIC_V9_VERSION, react: '^19.0.0', 'react-dom': '^19.0.0' } }, + null, + 2 + ), + }); + + expect(reactDeps.detect(ctx)).toEqual([]); + }); + it('does nothing when already on v9/v6 (version gate is closed)', () => { const ctx = createInMemoryContext({ 'package.json': JSON.stringify( @@ -65,6 +115,41 @@ describe('angular-deps', () => { }); }); +describe('core-deps', () => { + it('bumps a vanilla app @ionic/core pin to v9', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@ionic/core': '^8.4.0' } }, null, 2), + }); + + coreDeps.fix!(ctx); + const pkg = JSON.parse(ctx.readFile('package.json')!); + + expect(pkg.dependencies['@ionic/core']).toBe(IONIC_V9_VERSION); + }); + + it('bumps a directly pinned @ionic/core alongside a framework binding', () => { + // A framework app that also declares @ionic/core would otherwise keep a v8 + // core next to a v9 binding. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify( + { dependencies: { '@ionic/angular': '^8.4.0', '@ionic/core': '^8.4.0' } }, + null, + 2 + ), + }); + + expect(coreDeps.detect(ctx)).toHaveLength(1); + }); + + it('does nothing when @ionic/core is not a declared dependency', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@ionic/angular': '^8.4.0' } }, null, 2), + }); + + expect(coreDeps.detect(ctx)).toEqual([]); + }); +}); + describe('vue-deps', () => { it('bumps @ionic/vue and vue-router to their v9 targets', () => { const ctx = createInMemoryContext({ @@ -179,6 +264,49 @@ describe('react-router-6-code (report-only)', () => { expect(details.filter((d) => d.includes('regex path constraints removed'))).toHaveLength(1); }); + it('reports a Route that renders its content as children', () => { + const ctx = createInMemoryContext({ + 'App.tsx': `export const App = () => (\n \n \n \n);\n`, + }); + + const findings = reactRouter6Code.detect(ctx); + + expect(findings.some((f) => f.detail.includes('children'))).toBe(true); + expect(findings.find((f) => f.detail.includes('children'))?.docsUrl).toBe( + `${V9_DOCS}#route-definition-changes` + ); + }); + + it('does not report a Route that already renders through element', () => { + const ctx = createInMemoryContext({ + 'App.tsx': `export const App = () => } />;\n`, + }); + + expect(reactRouter6Code.detect(ctx)).toEqual([]); + }); + + it('reports a nested route wrapper once, not once per route inside it', () => { + // The inner Route's parent node is the outer JsxElement, so a naive parent + // check attributes the outer element's children to the inner route too. + const ctx = createInMemoryContext({ + 'App.tsx': + `export const App = () => (\n \n } />\n \n);\n`, + }); + + const children = reactRouter6Code.detect(ctx).filter((f) => f.detail.includes('children')); + + expect(children).toHaveLength(1); + expect(children[0].line).toBe(2); + }); + + it('does not mistake whitespace between tags for route children', () => { + const ctx = createInMemoryContext({ + 'App.tsx': `export const App = () => (\n }>\n \n);\n`, + }); + + expect(reactRouter6Code.detect(ctx)).toEqual([]); + }); + it('ignores non-router imports and non-Route elements', () => { const ctx = createInMemoryContext({ 'App.tsx': From f5e258409e7fd870c6193808fb6ac6bef0f30ddd Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 13 Aug 2026 12:56:46 -0700 Subject: [PATCH 2/4] docs(migrate): move per-version coverage out of the README --- packages/migrate/README.md | 86 +++++++++---------------------------- packages/migrate/docs/v9.md | 69 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 66 deletions(-) create mode 100644 packages/migrate/docs/v9.md diff --git a/packages/migrate/README.md b/packages/migrate/README.md index e301259ad62..9a3ba6ca7af 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -41,54 +41,12 @@ Every breaking change is one of two kinds: - Report-only: a change that needs judgement (semantic rework, a dialect choice). The tool finds it and explains it, but won't rewrite it. -### Coverage (v8 to v9) - -| Change | Framework | Mode | -| --- | --- | --- | -| `@ionic/angular` -> `@ionic/angular/lazy`, `/standalone` -> `@ionic/angular` | Angular | auto | -| `@ionic/angular` package bump | Angular | auto | -| `moduleResolution: "node"` -> `"bundler"` in `tsconfig*.json` | Angular | auto | -| TypeScript raised to the 5.4 floor | Angular | auto | -| CSS `~` prefix removal in `@ionic/angular` imports | Angular | auto | -| Add `provideZoneChangeDetection()` to a standalone bootstrap (keep Zone.js) | Angular | auto | -| NgModule bootstrap zone provider | Angular | report | -| `IonicModule` deprecation (`provideIonicAngular()`) | Angular | report | -| Angular below the 18 floor | Angular | report | -| Angular 22's `OnPush` default and its Node floor | Angular | report | -| `@ionic/angular-toolkit` version bump | Angular | report | -| `@ionic/react` + React 18 + React Router v6 bumps, drop `@types/react-router*` | React | auto | -| `` removal, `component={X}` -> `element={}` | React | auto | -| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, route children, `history` prop, regex paths | React | report | -| `@ionic/vue` + Vue Router 5 + Vue 3.5 bumps | Vue | auto | -| `next()` in navigation guards | Vue | report | -| `@ionic/core` package bump | all | auto | -| `autocorrect="off"` on `ion-input`/`ion-searchbar` | all | auto | -| `browserslist` entries raised to the v9 browser floors | all | auto | -| Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | all | report | -| `ion-img` deprecation | all | report | -| `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | all | report | -| `@ionic/core` imports outside the new `exports` allowlist | all | report | -| Capacitor 2 no longer detected as a native platform | all | report | -| `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | all | report | -| `label-placement="floating"` with slotted start/end content | all | report | -| `ion-textarea` md min-height `56px` -> `72px` | all | report | -| `ion-modal` `handleBehavior` default (`"none"` -> `"cycle"`) | all | report | -| `ion-select` `ionChange` firing and the action sheet `selected` role | all | report | -| `swipeBackEnabled` config, now read once at outlet mount | all | report | - -### What you check by hand - -The tool can't point at the code these changes affect, so check them against the -[migration guide](https://ionicframework.com/docs/updating/9-0) yourself: - -- React Router v6 needs a `/*` suffix on any route whose element contains nested - routes or a child `IonRouterOutlet` (`path="/tabs"` -> `path="/tabs/*"`). - Knowing which routes those are means resolving each `element` back to what it - renders, so flagging every route without a suffix would be noise. -- Angular 22 defaults components to `OnPush`, so state mutated as a plain field - in an Ionic lifecycle hook stops re-rendering. The report flags Angular 22 in - `package.json`, but it doesn't find the affected components - `ng update` has a - migration for that. +### Coverage + +Each major upgrade has its own page, listing every change the tool covers, which +kind it is, and what it leaves to you: + +- [v8 to v9](./docs/v9.md) ## How it works @@ -109,27 +67,18 @@ literals, comments, or unrelated code. ## Limitations -- Single-shot. A project migrator isn't idempotent, and the standalone import - swap in particular will corrupt already-migrated code if you re-run it. The - version gate prevents that once the `@ionic/*` bump has landed. -- Angular zoneless is auto-fixed only for the standalone `bootstrapApplication` - shape. NgModule apps are flagged for manual migration instead. -- Angular inline templates (a `template:` string in a decorator) and `.js`/`.jsx` - files are report-only for template changes. The auto-fix covers external - `.html`, `.vue`, and `.tsx`. -- The `ion-img` deprecation is report-only. +These hold for every major. What a given upgrade can't reach is on its own page +under [`docs/`](./docs). + +- Single-shot. A project migrator isn't idempotent, and an import rewrite in + particular will corrupt already-migrated code if you re-run it. The version + gate prevents that once the `@ionic/*` bump has landed. +- Only `.ts` and `.tsx` are loaded into `ts-morph`, so `.js`/`.jsx` files and + Angular inline templates (a `template:` string in a decorator) get the + text-scan migrations but not the AST-based ones. - The template scanner is best-effort, not a full HTML parser. -- The component DOM/shadow-part changes are report-only. The right replacement - depends on what the CSS rule was doing, and `ion-select`'s `part="inner"` has - none at all. - Stylesheet scanning covers `.css` and `.scss` files. Styles inlined in a component decorator's `styles` array aren't read. -- Only a `.browserslistrc` or `browserslist` file is read, so an app that keeps - the list in `package.json` (the CRA and Vite starters do) needs its browser - floors raised by hand. -- Angular's `moduleResolution` fix skips a tsconfig whose `module` is CommonJS. - TypeScript rejects `bundler` resolution there, and a Node-side config doesn't - resolve `@ionic/angular` subpaths anyway. ## Extending @@ -140,6 +89,11 @@ automate, a `fix()`, plus `fromMajor`/`toMajor` for version scoping and a fixture-backed test. The engine handles selection, ordering, git safety, formatting, and reporting. +Then add a row to that major's page under [`docs/`](./docs), creating +`docs/v.md` if it's the first migration for a new one. That page is what +tells someone whether an upgrade is covered, so it's part of the migration, not +an afterthought. + ## Development ```sh diff --git a/packages/migrate/docs/v9.md b/packages/migrate/docs/v9.md new file mode 100644 index 00000000000..eecc2c00abb --- /dev/null +++ b/packages/migrate/docs/v9.md @@ -0,0 +1,69 @@ +# Migrating v8 to v9 + +What `npx @ionic/migrate` covers for the Ionic 8 to Ionic 9 upgrade, and what it +leaves to you. The full list of breaking changes, with before/after examples, is +in the [Ionic 9 upgrade guide](https://ionicframework.com/docs/updating/9-0). +Every finding the tool prints links to the section of that guide it came from. + +## Coverage + +| Change | Framework | Mode | +| --- | --- | --- | +| `@ionic/angular` -> `@ionic/angular/lazy`, `/standalone` -> `@ionic/angular` | Angular | auto | +| `@ionic/angular` package bump | Angular | auto | +| `moduleResolution: "node"` -> `"bundler"` in `tsconfig*.json` | Angular | auto | +| TypeScript raised to the 5.4 floor | Angular | auto | +| CSS `~` prefix removal in `@ionic/angular` imports | Angular | auto | +| Add `provideZoneChangeDetection()` to a standalone bootstrap (keep Zone.js) | Angular | auto | +| NgModule bootstrap zone provider | Angular | report | +| `IonicModule` deprecation (`provideIonicAngular()`) | Angular | report | +| Angular below the 18 floor | Angular | report | +| Angular 22's `OnPush` default and its Node floor | Angular | report | +| `@ionic/angular-toolkit` version bump | Angular | report | +| `@ionic/react` + React 18 + React Router v6 bumps, drop `@types/react-router*` | React | auto | +| `` removal, `component={X}` -> `element={}` | React | auto | +| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, route children, `history` prop, regex paths | React | report | +| `@ionic/vue` + Vue Router 5 + Vue 3.5 bumps | Vue | auto | +| `next()` in navigation guards | Vue | report | +| `@ionic/core` package bump | all | auto | +| `autocorrect="off"` on `ion-input`/`ion-searchbar` | all | auto | +| `browserslist` entries raised to the v9 browser floors | all | auto | +| Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | all | report | +| `ion-img` deprecation | all | report | +| `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | all | report | +| `@ionic/core` imports outside the new `exports` allowlist | all | report | +| Capacitor 2 no longer detected as a native platform | all | report | +| `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | all | report | +| `label-placement="floating"` with slotted start/end content | all | report | +| `ion-textarea` md min-height `56px` -> `72px` | all | report | +| `ion-modal` `handleBehavior` default (`"none"` -> `"cycle"`) | all | report | +| `ion-select` `ionChange` firing and the action sheet `selected` role | all | report | +| `swipeBackEnabled` config, now read once at outlet mount | all | report | + +## What you check by hand + +The tool can't point at the code these changes affect, so check them against the +[upgrade guide](https://ionicframework.com/docs/updating/9-0) yourself: + +- React Router v6 needs a `/*` suffix on any route whose element contains nested + routes or a child `IonRouterOutlet` (`path="/tabs"` -> `path="/tabs/*"`). + Knowing which routes those are means resolving each `element` back to what it + renders, so flagging every route without a suffix would be noise. +- Angular 22 defaults components to `OnPush`, so state mutated as a plain field + in an Ionic lifecycle hook stops re-rendering. The report flags Angular 22 in + `package.json`, but it doesn't find the affected components - `ng update` has a + migration for that. + +## Notes on individual migrations + +- Angular zoneless is auto-fixed only for the standalone `bootstrapApplication` + shape. NgModule apps are flagged for manual migration instead. +- The component DOM/shadow-part changes are report-only. The right replacement + depends on what the CSS rule was doing, and `ion-select`'s `part="inner"` has + none at all. +- Only a `.browserslistrc` or `browserslist` file is read, so an app that keeps + the list in `package.json` (the CRA and Vite starters do) needs its browser + floors raised by hand. +- Angular's `moduleResolution` fix skips a tsconfig whose `module` is CommonJS. + TypeScript rejects `bundler` resolution there, and a Node-side config doesn't + resolve `@ionic/angular` subpaths anyway. From 80663747c2f8de1e73875c46f99c8f799cdb5cc3 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 14 Aug 2026 13:56:25 -0700 Subject: [PATCH 3/4] feat(migrate): cover angular's browser policy and gate zoneless on zone.js --- packages/migrate/README.md | 5 +- packages/migrate/docs/v9.md | 24 ++- packages/migrate/src/ast/browserslist.ts | 188 ++++++++++++++++ packages/migrate/src/ast/package-json.ts | 9 +- packages/migrate/src/context.ts | 76 ++++++- packages/migrate/src/detect.ts | 11 + packages/migrate/src/main.ts | 5 + packages/migrate/src/migrations/index.ts | 6 + .../v9/angular-browser-policy-manual.ts | 66 ++++++ .../migrations/v9/angular-browser-policy.ts | 188 ++++++++++++++++ .../migrations/v9/angular-zoneless-manual.ts | 7 +- .../src/migrations/v9/angular-zoneless.ts | 190 +++++++++++++++- .../migrations/v9/core-browserslist-manual.ts | 56 +++++ .../src/migrations/v9/core-browserslist.ts | 72 ++----- .../src/migrations/v9/core-capacitor.ts | 28 +-- packages/migrate/src/types.ts | 4 +- .../angular-browser-policy-manual.test.ts | 105 +++++++++ .../test/angular-browser-policy.test.ts | 150 +++++++++++++ .../test/angular-zoneless-manual.test.ts | 11 + .../migrate/test/angular-zoneless.test.ts | 203 +++++++++++++++++- packages/migrate/test/context.test.ts | 64 +++++- .../test/core-browserslist-manual.test.ts | 56 +++++ .../migrate/test/core-browserslist.test.ts | 49 +++++ packages/migrate/test/core-capacitor.test.ts | 13 ++ .../migrate/test/helpers/angular-policy.ts | 46 ++++ packages/migrate/test/main.test.ts | 11 +- 26 files changed, 1546 insertions(+), 97 deletions(-) create mode 100644 packages/migrate/src/ast/browserslist.ts create mode 100644 packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts create mode 100644 packages/migrate/src/migrations/v9/angular-browser-policy.ts create mode 100644 packages/migrate/src/migrations/v9/core-browserslist-manual.ts create mode 100644 packages/migrate/test/angular-browser-policy-manual.test.ts create mode 100644 packages/migrate/test/angular-browser-policy.test.ts create mode 100644 packages/migrate/test/core-browserslist-manual.test.ts create mode 100644 packages/migrate/test/helpers/angular-policy.ts diff --git a/packages/migrate/README.md b/packages/migrate/README.md index 9a3ba6ca7af..055ad57a47a 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -35,11 +35,14 @@ tree, so git is your undo. Then review the diff and the checklist it prints. ## What it does -Every breaking change is one of two kinds: +Every breaking change is one of three kinds: - Auto-fix: a deterministic edit that preserves behavior, applied for you. - Report-only: a change that needs judgement (semantic rework, a dialect choice). The tool finds it and explains it, but won't rewrite it. +- Experimental: an edit that is correct but whose consequence is a judgement + call, so it only runs with `--experimental`. A report-only migration covers + the same change by default. ### Coverage diff --git a/packages/migrate/docs/v9.md b/packages/migrate/docs/v9.md index eecc2c00abb..d3d9affead0 100644 --- a/packages/migrate/docs/v9.md +++ b/packages/migrate/docs/v9.md @@ -28,11 +28,14 @@ Every finding the tool prints links to the section of that guide it came from. | `@ionic/core` package bump | all | auto | | `autocorrect="off"` on `ion-input`/`ion-searchbar` | all | auto | | `browserslist` entries raised to the v9 browser floors | all | auto | +| Browsers from the v9 list a `browserslist` doesn't name | all | report | +| `browserslist` entries below Angular 20+'s own browser policy | Angular | report | +| `browserslist` entries raised to Angular's policy floors | Angular | experimental | | Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | all | report | | `ion-img` deprecation | all | report | | `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | all | report | | `@ionic/core` imports outside the new `exports` allowlist | all | report | -| Capacitor 2 no longer detected as a native platform | all | report | +| Capacitor below the 7 Ionic 9 supports, and the Capacitor 2 native-detection break | all | report | | `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | all | report | | `label-placement="floating"` with slotted start/end content | all | report | | `ion-textarea` md min-height `56px` -> `72px` | all | report | @@ -40,6 +43,8 @@ Every finding the tool prints links to the section of that guide it came from. | `ion-select` `ionChange` firing and the action sheet `selected` role | all | report | | `swipeBackEnabled` config, now read once at outlet mount | all | report | +`experimental` migrations only run with `--experimental`. + ## What you check by hand The tool can't point at the code these changes affect, so check them against the @@ -57,13 +62,22 @@ The tool can't point at the code these changes affect, so check them against the ## Notes on individual migrations - Angular zoneless is auto-fixed only for the standalone `bootstrapApplication` - shape. NgModule apps are flagged for manual migration instead. + shape. NgModule apps are flagged for manual migration instead. Neither fires + unless the app loads Zone.js, since there is nothing to preserve otherwise and + the provider fails to bootstrap without it. - The component DOM/shadow-part changes are report-only. The right replacement depends on what the CSS rule was doing, and `ion-select`'s `part="inner"` has none at all. -- Only a `.browserslistrc` or `browserslist` file is read, so an app that keeps - the list in `package.json` (the CRA and Vite starters do) needs its browser - floors raised by hand. +- A `browserslist` list is read from `.browserslistrc`, a `browserslist` file, or + the `package.json` field. A query-style list (`last 2 versions`, `> 0.5%`) + names no browser, so nothing in it is raised or reported. +- Angular's own browser policy, which the CLI enforces from Angular 20 on, is + read from the installed `@angular/build` and resolved with the project's + `browserslist`. Without `node_modules` the report names the policy but not the + versions. The two things it reads are Angular internals (a `.browserslistrc` in + the package on 20, a `BASELINE_DATE` constant on 21+), so when a new Angular + major lands, check the reported floors against the `ng build` warning before + trusting them. - Angular's `moduleResolution` fix skips a tsconfig whose `module` is CommonJS. TypeScript rejects `bundler` resolution there, and a Node-side config doesn't resolve `@ionic/angular` subpaths anyway. diff --git a/packages/migrate/src/ast/browserslist.ts b/packages/migrate/src/ast/browserslist.ts new file mode 100644 index 00000000000..0c14389a182 --- /dev/null +++ b/packages/migrate/src/ast/browserslist.ts @@ -0,0 +1,188 @@ +import { compareVersions } from '../detect.js'; +import { writePackageJson } from './package-json.js'; +import type { PackageJson } from './package-json.js'; +import type { MigrationContext } from '../context.js'; + +/** + * Reading and rewriting the browserslist a project declares, shared by the four + * browserslist migrations: `core-browserslist` and `angular-browser-policy` raise + * floors, their `-manual` companions report against them. Entries are edited in + * place. + */ +/** + * A `Name >=Version` entry, the shape the Ionic starters generate. The version + * is captured whole so raising `Safari >=15.4` writes `>=16`, not `>=16.4`. The + * optional `\r` keeps a CRLF checkout from matching nothing. + */ +const ENTRY = /^(\s*)([A-Za-z_]+)(\s*>=\s*)(\d+(?:\.\d+)*)(.*?)\r?$/; + +const BROWSERSLIST_GLOBS = ['**/.browserslistrc', '**/browserslist']; + +/** + * The browser an entry names, lowercased, or `undefined` for a query + * (`last 2 versions`) rather than a named entry. + */ +export function entryBrowser(entry: string): string | undefined { + return ENTRY.exec(entry)?.[2].toLowerCase(); +} + +/** A browserslist entry rewritten to meet a floor. */ +export interface RaisedEntry { + /** Browser name as the project wrote it. */ + name: string; + from: string; + to: string; + /** The whole line, rewritten. */ + line: string; +} + +/** + * The raised version of a browserslist line against a set of floors, or + * `undefined` when the line is not a named entry or already meets its floor. + * Shared by detect/fix so the report and the edit can never disagree. + * + * Floors compare as dotted versions, so an integer floor of `16` is met by + * `Safari >=16.3`, and a floor of `16.4` is not. + */ +export function raiseEntry(line: string, floors: Record): RaisedEntry | undefined { + const m = ENTRY.exec(line); + if (!m) return undefined; + const [, indent, name, op, version, rest] = m; + const floor = floors[name.toLowerCase()]; + if (floor === undefined) return undefined; + const to = String(floor); + if (compareVersions(version, to) >= 0) return undefined; + const crlf = line.endsWith('\r') ? '\r' : ''; + return { name, from: version, to, line: `${indent}${name}${op}${to}${rest}${crlf}` }; +} + +/** + * The `browserslist` field's entries, flattened. The field is a query string, an + * array of them, or an object keyed by environment (`production`, `development`) + * whose values are either - so all three shapes are walked the same way. + */ +function fieldEntries(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(fieldEntries); + if (value && typeof value === 'object') return Object.values(value).flatMap(fieldEntries); + return []; +} + +/** Every entry of a `browserslist` field value, rewritten through `map`. */ +function mapField(value: unknown, map: (entry: string) => string): unknown { + if (typeof value === 'string') return map(value); + if (Array.isArray(value)) return value.map((v) => mapField(v, map)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([env, v]) => [env, mapField(v, map)])); + } + return value; +} + +/** 1-based line of the character at `index`. A missing index (`-1`) reads as line 1. */ +function lineOf(text: string, index: number): number { + return index === -1 ? 1 : text.slice(0, index).split('\n').length; +} + +/** + * Where the `browserslist` field itself starts. Matched with its opening bracket + * so a `"browserslist"` devDependency, which holds a string, isn't taken for it. + */ +function fieldLine(pkgText: string): number { + return lineOf(pkgText, pkgText.search(/"browserslist"\s*:\s*[[{]/)); +} + +/** A manifest that declares a `browserslist` field, with its raw text. */ +interface PackageBrowserslist { + filePath: string; + pkg: PackageJson; + text: string; + field: unknown; +} + +/** + * Every `package.json` declaring a `browserslist`, globbed like the list files + * are: a workspace keeps one manifest per app, and only some of them set it. + */ +function packageBrowserslists(ctx: MigrationContext): PackageBrowserslist[] { + const found: PackageBrowserslist[] = []; + for (const filePath of ctx.glob(['**/package.json'])) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + let pkg: PackageJson; + try { + pkg = JSON.parse(text) as PackageJson; + } catch { + continue; + } + if (pkg.browserslist !== undefined) found.push({ filePath, pkg, text, field: pkg.browserslist }); + } + return found; +} + +/** One place a project declares its browserslist, flattened for reporting. */ +export interface BrowserslistSource { + /** Path to report findings against. */ + filePath: string; + /** 1-based line the list itself starts at. */ + line: number; + /** Every entry in the list, with the line it sits on. */ + entries: { text: string; line: number }[]; +} + +/** + * Every browserslist the project declares: the `.browserslistrc`/`browserslist` + * files, and the `package.json` field the Angular starters generate. + */ +export function browserslistSources(ctx: MigrationContext): BrowserslistSource[] { + const sources: BrowserslistSource[] = []; + + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + sources.push({ + filePath, + line: 1, + entries: text.split('\n').map((line, i) => ({ text: line, line: i + 1 })), + }); + } + + for (const pkg of packageBrowserslists(ctx)) { + // Entries are located in order from a moving cursor, so the same query under + // two environment keys reports two lines rather than the first one twice. + let cursor = 0; + sources.push({ + filePath: pkg.filePath, + line: fieldLine(pkg.text), + entries: fieldEntries(pkg.field).map((entry) => { + const index = pkg.text.indexOf(JSON.stringify(entry), cursor); + if (index !== -1) cursor = index + 1; + return { text: entry, line: lineOf(pkg.text, index) }; + }), + }); + } + + return sources; +} + +/** + * Rewrite every entry of every browserslist the project declares through `map`, + * writing back only the sources that changed. + * + * The manifest path reserializes the whole file (2-space, trailing newline), the + * same way the version bump already does. + */ +export function rewriteBrowserslists(ctx: MigrationContext, map: (entry: string) => string): void { + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + const next = text.split('\n').map(map).join('\n'); + if (next !== text) ctx.writeFile(filePath, next); + } + + for (const pkg of packageBrowserslists(ctx)) { + const nextField = mapField(pkg.field, map); + if (JSON.stringify(nextField) !== JSON.stringify(pkg.field)) { + writePackageJson(ctx, { ...pkg.pkg, browserslist: nextField }, pkg.filePath); + } + } +} diff --git a/packages/migrate/src/ast/package-json.ts b/packages/migrate/src/ast/package-json.ts index 75da7bb1054..aacfba89e2e 100644 --- a/packages/migrate/src/ast/package-json.ts +++ b/packages/migrate/src/ast/package-json.ts @@ -17,9 +17,12 @@ export function readPackageJson(ctx: MigrationContext): { pkg: PackageJson } | u } } -/** Serialize and write `package.json`, keeping 2-space indent and a trailing newline. */ -export function writePackageJson(ctx: MigrationContext, pkg: PackageJson): void { - ctx.writeFile('package.json', `${JSON.stringify(pkg, null, 2)}\n`); +/** + * Serialize and write a `package.json`, keeping 2-space indent and a trailing + * newline. Defaults to the project's own; `filePath` targets a workspace manifest. + */ +export function writePackageJson(ctx: MigrationContext, pkg: PackageJson, filePath = 'package.json'): void { + ctx.writeFile(filePath, `${JSON.stringify(pkg, null, 2)}\n`); } /** Which dependency block a package lives in. */ diff --git a/packages/migrate/src/context.ts b/packages/migrate/src/context.ts index 5ffc2bd2178..4a012bbbd86 100644 --- a/packages/migrate/src/context.ts +++ b/packages/migrate/src/context.ts @@ -1,3 +1,5 @@ +import { createRequire } from 'node:module'; + import { Project, QuoteKind } from 'ts-morph'; /** ts-morph should emit single-quoted strings to match Ionic/Angular style. */ @@ -48,18 +50,34 @@ function EXCLUDE_GLOBS(root: string): string[] { * after the ts-morph save, so writing a `.ts`/`.tsx` file ts-morph also holds * would override its edits. Today no migration edits a loaded file both ways, so * a given file is only ever touched through one view. + * + * {@link requireFromProject}/{@link resolveFromProject} are the exception: they + * reach the real module graph rather than that filesystem, so a migration can + * read a tool's own config from the version the project builds with. Tests hand + * over stubs instead. */ export interface MigrationContext { /** Project root; all relative paths resolve against it. */ readonly rootDir: string; /** ts-morph project holding the loaded `.ts`/`.tsx` source files. */ readonly project: Project; - /** Read a file's text, or `undefined` if it does not exist. */ - readFile(relPath: string): string | undefined; + /** Read a file's text by project-relative or absolute path, or `undefined`. */ + readFile(path: string): string | undefined; /** Buffer a file's text (creating it if needed); persisted by {@link save}. */ writeFile(relPath: string, content: string): void; /** Return paths (relative to {@link rootDir}) matching the given glob patterns. */ glob(patterns: string[]): string[]; + /** + * Load a package from the project's own `node_modules`, or `undefined` when it + * is not installed. A fresh clone has none, so callers need a report-only path. + */ + requireFromProject(specifier: string): T | undefined; + /** + * Absolute path a specifier resolves to in the project, or `undefined` when it + * is not installed. Locates a package whose layout differs per package manager + * (pnpm links, hoisting) instead of guessing at `node_modules` paths. + */ + resolveFromProject(specifier: string): string | undefined; /** Convert an absolute path to one relative to {@link rootDir}. */ relative(absPath: string): string; /** @@ -71,7 +89,13 @@ export interface MigrationContext { save(): void; } -function buildContext(rootDir: string, project: Project): MigrationContext { +/** How a context reaches the packages the project has installed. */ +interface ProjectModules { + require(specifier: string): T | undefined; + resolve(specifier: string): string | undefined; +} + +function buildContext(rootDir: string, project: Project, modules: ProjectModules): MigrationContext { const fs = project.getFileSystem(); const touched = new Set(); // Text writes are buffered here and flushed only by `save()`. A run that @@ -88,9 +112,13 @@ function buildContext(rootDir: string, project: Project): MigrationContext { rootDir, project, touchedFiles: touched, - readFile(relPath) { - if (pendingWrites.has(relPath)) return pendingWrites.get(relPath); - const abs = join(rootDir, relPath); + readFile(path) { + if (pendingWrites.has(path)) return pendingWrites.get(path); + // Absolute paths pass through, so a caller can read a file located by + // `resolveFromProject`, which can land outside `rootDir` entirely (a + // hoisted workspace, a pnpm store). Windows keeps its drive letter through + // that normalization, so it counts as absolute too. + const abs = /^(\/|[A-Za-z]:\/)/.test(path) ? path : join(rootDir, path); return fs.fileExistsSync(abs) ? fs.readFileSync(abs) : undefined; }, writeFile(relPath, content) { @@ -109,6 +137,8 @@ function buildContext(rootDir: string, project: Project): MigrationContext { .filter((rel) => !EXCLUDE_RE.test(rel)); }, relative: toRelative, + requireFromProject: modules.require, + resolveFromProject: modules.resolve, save() { // Record files ts-morph is about to write so the formatter can find them. for (const file of project.getSourceFiles()) { @@ -130,7 +160,11 @@ function buildContext(rootDir: string, project: Project): MigrationContext { * entries are loaded as ts-morph source files. Everything else is written as a * plain file. Used by tests. */ -export function createInMemoryContext(files: Record, rootDir = '/app'): MigrationContext { +export function createInMemoryContext( + files: Record, + rootDir = '/app', + modules: Record = {} +): MigrationContext { const project = new Project({ useInMemoryFileSystem: true, manipulationSettings: MANIPULATION_SETTINGS, @@ -146,7 +180,16 @@ export function createInMemoryContext(files: Record, rootDir = ' join(rootDir, '**/*.tsx'), ...EXCLUDE_GLOBS(rootDir), ]); - return buildContext(rootDir, project); + // Nothing is installed in memory, so only what a test hands over is loadable. + return buildContext(rootDir, project, { + require: (specifier: string) => modules[specifier] as T | undefined, + // Resolved against the in-memory `node_modules` so a test can lay out a + // package the way the real one is installed. + resolve: (specifier: string) => { + const path = join(rootDir, `node_modules/${specifier}`); + return fs.fileExistsSync(path) ? path : undefined; + }, + }); } /** @@ -169,5 +212,20 @@ export function createDiskContext(rootDir: string): MigrationContext { join(rootDir, '**/*.tsx'), ...EXCLUDE_GLOBS(rootDir), ]); - return buildContext(rootDir, project); + // Resolved from the project root, not this package, so a migration reads the + // project's installed copy. + const projectRequire = createRequire(join(rootDir, 'package.json')); + const attempt = (load: () => T): T | undefined => { + try { + return load(); + } catch { + return undefined; + } + }; + return buildContext(rootDir, project, { + require: (specifier: string) => attempt(() => projectRequire(specifier) as T), + // Normalized to posix separators: callers slice these paths and hand them to + // `readFile`, which is posix-relative. + resolve: (specifier: string) => attempt(() => projectRequire.resolve(specifier).replace(/\\/g, '/')), + }); } diff --git a/packages/migrate/src/detect.ts b/packages/migrate/src/detect.ts index 800f848157e..9f0f366825e 100644 --- a/packages/migrate/src/detect.ts +++ b/packages/migrate/src/detect.ts @@ -27,6 +27,17 @@ export function parseMajor(range: string | undefined): number | undefined { return match ? Number(match[1]) : undefined; } +/** Compare dotted numeric versions, so `16.10` sorts above `16.4`. */ +export function compareVersions(a: string, b: string): number { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + /** * Whether a range is a plain, comparable semver range rather than a * protocol/alias reference (`workspace:`, `catalog:`, `npm:`, `file:`, `link:`, diff --git a/packages/migrate/src/main.ts b/packages/migrate/src/main.ts index 48557f65556..c0012b87824 100644 --- a/packages/migrate/src/main.ts +++ b/packages/migrate/src/main.ts @@ -201,6 +201,11 @@ export function main(argv: string[]): number { const formatted = formatTouched(ctx, prettierFormatter); if (formatted.length > 0) { console.log(dim(`\nFormatted ${formatted.length} changed file(s) with Prettier.`)); + } else if (ctx.touchedFiles.size > 0) { + // Silence here reads as "formatted", so say the project has no Prettier. + console.log( + dim(`\nLeft ${ctx.touchedFiles.size} changed file(s) as written: no Prettier installed in this project.`) + ); } } catch (e) { // Formatting is cosmetic and runs after the edits are already on disk, so diff --git a/packages/migrate/src/migrations/index.ts b/packages/migrate/src/migrations/index.ts index 5cd3f0ce7a3..65ae39e5b78 100644 --- a/packages/migrate/src/migrations/index.ts +++ b/packages/migrate/src/migrations/index.ts @@ -9,12 +9,15 @@ import { angularIonicModule } from './v9/angular-ionic-module.js'; import { angularModuleResolution } from './v9/angular-module-resolution.js'; import { angularTypescript } from './v9/angular-typescript.js'; import { angularVersion } from './v9/angular-version.js'; +import { angularBrowserPolicy } from './v9/angular-browser-policy.js'; +import { angularBrowserPolicyManual } from './v9/angular-browser-policy-manual.js'; import { reactDeps } from './v9/react-deps.js'; import { reactRouter6Routes } from './v9/react-router-6-routes.js'; import { reactRouter6Code } from './v9/react-router-6-code.js'; import { vueDeps } from './v9/vue-deps.js'; import { vueRouterNextGuard } from './v9/vue-router-next-guard.js'; import { coreBrowserslist } from './v9/core-browserslist.js'; +import { coreBrowserslistManual } from './v9/core-browserslist-manual.js'; import { coreCapacitor } from './v9/core-capacitor.js'; import { coreDeps } from './v9/core-deps.js'; import { coreFloatingLabel } from './v9/core-floating-label.js'; @@ -45,12 +48,15 @@ export const allMigrations: Migration[] = [ angularModuleResolution, angularTypescript, angularVersion, + angularBrowserPolicy, + angularBrowserPolicyManual, reactDeps, reactRouter6Routes, reactRouter6Code, vueDeps, vueRouterNextGuard, coreBrowserslist, + coreBrowserslistManual, coreCapacitor, coreDeps, coreFloatingLabel, diff --git a/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts b/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts new file mode 100644 index 00000000000..fb600090309 --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts @@ -0,0 +1,66 @@ +import type { Finding, Migration } from '../../types.js'; +import { angularMajor, angularPolicyFloors, BROWSER_POLICY_ANGULAR } from './angular-browser-policy.js'; +import { browserslistSources, raiseEntry } from '../../ast/browserslist.js'; + +/** + * Report-only companion to the experimental `angular-browser-policy` auto-fix. + * + * The v9 guide prints one browserslist for every framework, but from Angular 20 + * on the CLI enforces its own, higher floors on top of it: `ng build` warns for + * every browser version below them and Angular supports none of those browsers. + * So the guide's block cannot be taken at face value in an Angular app, and this + * says which entries fall short. + * + * Report-only by default. `--experimental` applies it, and `angular-browser-policy` + * says why. + * + * See https://ionicframework.com/docs/updating/9-0#browser-support + */ +export const angularBrowserPolicyManual: Migration = { + id: 'angular-browser-policy-manual', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const major = angularMajor(ctx); + if (major === undefined || major < BROWSER_POLICY_ANGULAR) return []; + + const sources = browserslistSources(ctx); + // No list of its own means Angular's defaults already apply, and there is + // nothing to align. + if (sources.length === 0) return []; + + const floors = angularPolicyFloors(ctx); + if (floors === undefined) { + // Dependencies are not installed (or Angular changed where it keeps the + // policy), so the exact floors are out of reach. + return [ + { + filePath: sources[0].filePath, + line: sources[0].line, + detail: + `Angular ${major} applies its own browser support policy on top of your browserslist. ` + + `It is stricter than Ionic 9's floors, and \`ng build\` warns for every browser below it`, + }, + ]; + } + + const findings: Finding[] = []; + for (const source of sources) { + for (const entry of source.entries) { + const raised = raiseEntry(entry.text, floors); + if (raised) { + findings.push({ + filePath: source.filePath, + line: entry.line, + detail: `${raised.name} >=${raised.from} is below Angular ${major}'s browser policy. Raise it to >=${raised.to}`, + }); + } + } + } + return findings; + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-browser-policy.ts b/packages/migrate/src/migrations/v9/angular-browser-policy.ts new file mode 100644 index 00000000000..e36ac493aac --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-browser-policy.ts @@ -0,0 +1,188 @@ +import { compareVersions, isPlainSemverRange, parseMajor } from '../../detect.js'; +import { findDependency, readPackageJson } from '../../ast/package-json.js'; +import type { MigrationContext } from '../../context.js'; +import type { Finding, Migration } from '../../types.js'; +import { browserslistSources, raiseEntry, rewriteBrowserslists } from '../../ast/browserslist.js'; + +/** + * Angular's own browser support policy, resolved the way the Angular CLI + * resolves it. Shared by `angular-browser-policy-manual` and the auto-fix below. + * + * From Angular 20 on, `@angular/build` warns for every browser below its policy, + * so an app on the v9 guide's floors (Chrome 89, Safari 16) builds with warnings. + * The policy is read from the installed `@angular/build` and evaluated with the + * project's own `browserslist` rather than copied into a table here: on 21+ it is + * a rolling `baseline widely available on ` query whose resolved versions + * depend on the project's `caniuse-lite`, so a table would drift. + * + * See https://ionicframework.com/docs/updating/9-0#browser-support + */ +/** The first Angular whose CLI enforces a browser support policy of its own. */ +export const BROWSER_POLICY_ANGULAR = 20; + +/** + * Packages to resolve `@angular/build` through. An app on the devkit builder has + * no direct dependency on it, so it resolves from that package instead. + */ +const BUILD_PACKAGE_OWNERS = ['@angular/build', '@angular-devkit/build-angular']; + +/** Angular 21+ resolves its policy from this constant instead of a static file. */ +const BASELINE_DATE = /BASELINE_DATE\s*=\s*['"](\d{4}-\d{2}-\d{2})['"]/; + +/** + * caniuse browser ids mapped to the browserslist config names projects write. + * + * `and_chr`/`and_ff` are left out on purpose. caniuse carries one version for the + * Android browsers, the current release, so any query resolves to that single + * entry - both Angular's policy and the project's own list. The CLI's comparison + * can never fail on them, and taking that version as a floor would report a + * warning the build does not emit and move with every `caniuse-lite` update. + */ +const CANIUSE_NAMES: Record = { + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + safari: 'Safari', + ios_saf: 'iOS', +}; + +/** The Angular major the project is on, when it is a comparable version. */ +export function angularMajor(ctx: MigrationContext): number | undefined { + const parsed = readPackageJson(ctx); + if (!parsed) return undefined; + const dep = findDependency(parsed.pkg, '@angular/core'); + if (!dep || !isPlainSemverRange(dep.range)) return undefined; + return parseMajor(dep.range); +} + +/** + * Absolute directories `@angular/build` is installed in. Resolved rather than + * guessed at: pnpm links it outside the importer's own `node_modules`, and a + * workspace can hoist it above the project. + */ +function buildPackageDirs(ctx: MigrationContext): string[] { + const dirs: string[] = []; + for (const owner of BUILD_PACKAGE_OWNERS) { + // Resolved through `package.json`, the one subpath a package's `exports` map + // can never block. + const manifest = ctx.resolveFromProject(`${owner}/package.json`); + if (manifest === undefined) continue; + // Kept absolute: the package can sit outside the project (hoisted, pnpm). + const dir = manifest.slice(0, manifest.lastIndexOf('/')); + // The devkit builder owns `@angular/build` as a dependency, so resolve on + // through it rather than reading the builder's own package. + dirs.push(owner === '@angular/build' ? dir : `${dir}/node_modules/@angular/build`); + } + return dirs; +} + +/** + * The browserslist query describing Angular's policy: the static file it ships + * on 20, or the baseline query it builds from `BASELINE_DATE` on 21+. + */ +function policyQuery(ctx: MigrationContext): string | string[] | undefined { + for (const dir of buildPackageDirs(ctx)) { + const staticList = ctx.readFile(`${dir}/.browserslistrc`); + if (staticList !== undefined) { + const queries = staticList + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + if (queries.length > 0) return queries; + } + + const date = BASELINE_DATE.exec(ctx.readFile(`${dir}/src/utils/supported-browsers.js`) ?? '')?.[1]; + if (date !== undefined) return `baseline widely available on ${date}`; + } + return undefined; +} + +/** + * The lowest version Angular supports for each browser a project can name, or + * `undefined` when the policy cannot be resolved - no `@angular/build` + * installed, no `browserslist` to evaluate it with, or a query neither + * understands. Callers must treat that as "report only", never as "no floors". + */ +export function angularPolicyFloors(ctx: MigrationContext): Record | undefined { + const query = policyQuery(ctx); + if (query === undefined) return undefined; + + const browserslist = ctx.requireFromProject<(q: string | string[]) => string[]>('browserslist'); + if (typeof browserslist !== 'function') return undefined; + + let resolved: string[]; + try { + resolved = browserslist(query); + } catch { + return undefined; + } + + const floors: Record = {}; + for (const entry of resolved) { + const [id, versions] = entry.split(' '); + const name = CANIUSE_NAMES[id]; + if (name === undefined || versions === undefined) continue; + // A resolved entry can cover a range (`ios_saf 16.6-16.7`). Its lower bound + // is the version actually supported from. + const version = versions.split('-')[0]; + // A non-numeric version (`safari TP`) would compare as NaN and then be + // written into the project's list verbatim. + if (!/^\d+(\.\d+)*$/.test(version)) continue; + const current = floors[name.toLowerCase()]; + if (current === undefined || compareVersions(version, current) < 0) { + floors[name.toLowerCase()] = version; + } + } + return Object.keys(floors).length > 0 ? floors : undefined; +} + +function applicableFloors(ctx: MigrationContext): Record | undefined { + const major = angularMajor(ctx); + if (major === undefined || major < BROWSER_POLICY_ANGULAR) return undefined; + return angularPolicyFloors(ctx); +} + +/** + * Raises every entry below Angular's policy. Experimental because it narrows the + * app's support matrix and raises the build's syntax target with it, so output + * can stop working on a browser that previously only warned. + * + * Runs before `angular-browser-policy-manual`, which reports the same entries: + * migrations are selected in id order, so the report re-reads the rewritten list + * and stays quiet. Renaming either id breaks that. + */ +export const angularBrowserPolicy: Migration = { + id: 'angular-browser-policy', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'experimental', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const floors = applicableFloors(ctx); + if (floors === undefined) return []; + + const major = angularMajor(ctx); + const findings: Finding[] = []; + for (const source of browserslistSources(ctx)) { + for (const entry of source.entries) { + const raised = raiseEntry(entry.text, floors); + if (raised) { + findings.push({ + filePath: source.filePath, + line: entry.line, + detail: `${raised.name} >=${raised.from} -> >=${raised.to} (Angular ${major} browser policy)`, + }); + } + } + } + return findings; + }, + + fix(ctx) { + const floors = applicableFloors(ctx); + if (floors === undefined) return; + rewriteBrowserslists(ctx, (entry) => raiseEntry(entry, floors)?.line ?? entry); + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts b/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts index 4541492f1aa..870e297e2c6 100644 --- a/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts +++ b/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts @@ -1,13 +1,16 @@ import { SyntaxKind } from 'ts-morph'; import type { Finding, Migration } from '../../types.js'; -import { ZONE_PROVIDER } from './angular-zoneless.js'; +import { loadsZoneJs, ZONE_PROVIDER } from './angular-zoneless.js'; /** * Report-only companion to `angular-zoneless`. NgModule apps bootstrap via * `bootstrapModule`, which the auto-fix can't edit, so flag them for a manual * zone-provider migration rather than dropping the warning they previously got. * + * Gated on Zone.js actually being loaded, for the same reason the auto-fix is: + * an app already running zoneless has no Zone.js behavior to preserve. + * * See https://ionicframework.com/docs/updating/9-0#zoneless-change-detection */ const DETAIL = @@ -27,7 +30,7 @@ export const angularZonelessManual: Migration = { // Already configured (zone or zoneless)? Nothing to warn about. if (ZONE_PROVIDER.test(file.getFullText())) continue; for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) { - if (call.getExpression().getText().endsWith('bootstrapModule')) { + if (call.getExpression().getText().endsWith('bootstrapModule') && loadsZoneJs(ctx, file.getFilePath())) { findings.push({ filePath: ctx.relative(file.getFilePath()), line: call.getStartLineNumber(), diff --git a/packages/migrate/src/migrations/v9/angular-zoneless.ts b/packages/migrate/src/migrations/v9/angular-zoneless.ts index 3fa85952a4a..8702876652b 100644 --- a/packages/migrate/src/migrations/v9/angular-zoneless.ts +++ b/packages/migrate/src/migrations/v9/angular-zoneless.ts @@ -21,6 +21,140 @@ export const ZONE_PROVIDER = /provide(Experimental)?Zone(less)?ChangeDetection/; const CORE_MODULE = '@angular/core'; const PROVIDER = 'provideZoneChangeDetection'; +/** + * A module specifier that loads Zone.js into the app: `zone.js`, + * `zone.js/dist/zone`. `zone.js/testing` is excluded - a karma setup that patches + * Zone.js for tests says nothing about how the app itself bootstraps. + */ +const ZONE_MODULE = /^zone\.js(?!\/testing)(\/|$)/; + +/** Workspace configs that declare the `polyfills` a build loads. */ +const WORKSPACE_CONFIGS = ['**/angular.json', '**/project.json', '**/workspace.json']; + +/** + * Every `polyfills` entry a parsed config declares. The `test` target is skipped: + * its polyfills belong to karma, not to the app's bootstrap, and the CLI still + * scaffolds Zone.js there for an app that runs zoneless. + */ +function polyfillsEntries(node: unknown): string[] { + if (Array.isArray(node)) return node.flatMap(polyfillsEntries); + if (node === null || typeof node !== 'object') return []; + const found: string[] = []; + for (const [key, value] of Object.entries(node as Record)) { + if (key === 'test') continue; + if (key === 'polyfills') { + for (const entry of Array.isArray(value) ? value : [value]) { + if (typeof entry === 'string') found.push(entry); + } + } + found.push(...polyfillsEntries(value)); + } + return found; +} + +/** A project declared by a workspace config, with the polyfills it loads. */ +interface DeclaredProject { + /** Directory the project lives in, relative to the workspace root. */ + root: string; + /** Entries its build loads, either module specifiers or workspace paths. */ + polyfills: string[]; +} + +/** + * A project's root as a plain relative prefix. A config can spell "here" as `''`, + * `'.'`, or `'./'`, and any of the last two would fail every `underRoot` test. + */ +function normalizeRoot(dir: string, declared: string): string { + return [dir, declared.replace(/^\.\/?/, '').replace(/\/+$/, '')].filter(Boolean).join('/'); +} + +/** Whether a project-relative path sits under `root` (`''` is the whole tree). */ +function underRoot(path: string, root: string): boolean { + return root === '' || path === root || path.startsWith(`${root}/`); +} + +/** + * Every project any workspace config declares. A monorepo's `angular.json` lists + * one entry per app, each with its own `root` and `polyfills`, so they have to be + * read separately - one app keeping Zone.js says nothing about its neighbor. + */ +function declaredProjects(ctx: MigrationContext): DeclaredProject[] { + const projects: DeclaredProject[] = []; + for (const configPath of ctx.glob(WORKSPACE_CONFIGS)) { + const text = ctx.readFile(configPath); + if (text === undefined) continue; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + // A config we cannot parse tells us nothing; the import scan still applies. + continue; + } + const dir = configPath.includes('/') ? configPath.slice(0, configPath.lastIndexOf('/')) : ''; + const entries = (parsed as { projects?: unknown }).projects; + if (entries !== null && typeof entries === 'object') { + for (const project of Object.values(entries as Record)) { + const declared = (project as { root?: unknown }).root; + const root = typeof declared === 'string' ? declared : ''; + projects.push({ root: normalizeRoot(dir, root), polyfills: polyfillsEntries(project) }); + } + } else { + // Nx's `project.json` holds a single project, rooted where it sits. + projects.push({ root: normalizeRoot(dir, ''), polyfills: polyfillsEntries(parsed) }); + } + } + return projects; +} + +/** Whether any of these source files imports Zone.js. */ +function importsZone(ctx: MigrationContext, matches: (path: string) => boolean): boolean { + return ctx.project + .getSourceFiles() + .some( + (file) => + matches(ctx.relative(file.getFilePath())) && + file.getImportDeclarations().some((d) => ZONE_MODULE.test(d.getModuleSpecifierValue())) + ); +} + +/** + * Whether the project owning `filePath` loads Zone.js. + * `provideZoneChangeDetection()` throws at bootstrap without it, so an app + * already running zoneless (Angular 21+ scaffolds omit Zone.js entirely) must be + * left alone - there is no Zone.js behavior to preserve. + * + * A `zone.js` dependency in `package.json` is not enough on its own, so this + * looks at what the build actually loads: the project's `polyfills` entries, + * either naming the module directly or pointing at a file that imports it. A + * project with no declared polyfills falls back to scanning its own directory, + * which is all a config-less project (or an Nx target shape we don't read) leaves + * to go on. + */ +export function loadsZoneJs(ctx: MigrationContext, filePath: string): boolean { + const projects = declaredProjects(ctx); + // The innermost project containing a path. Several configs can declare the same + // root, so they merge rather than one shadowing the other by glob order. + const ownerOf = (path: string): { root: string; polyfills: string[] } | undefined => { + const matches = projects.filter((project) => underRoot(path, project.root)); + if (matches.length === 0) return undefined; + const root = matches.reduce((deepest, p) => (p.root.length > deepest.length ? p.root : deepest), ''); + return { root, polyfills: matches.filter((p) => p.root === root).flatMap((p) => p.polyfills) }; + }; + + const owner = ownerOf(ctx.relative(filePath)); + + if (owner !== undefined && owner.polyfills.length > 0) { + if (owner.polyfills.some((entry) => ZONE_MODULE.test(entry))) return true; + // Declared polyfills are workspace-relative paths, so only those files count. + const declared = new Set(owner.polyfills.map((entry) => entry.replace(/^\.\//, ''))); + return importsZone(ctx, (sourcePath) => declared.has(sourcePath)); + } + + // Nothing declared, so fall back to the project's own files, excluding any that + // belong to a project nested inside it. + return importsZone(ctx, (sourcePath) => ownerOf(sourcePath)?.root === owner?.root); +} + /** * The `providers` array of every standalone bootstrap that lacks any * zone/zoneless provider. Returns all matches so monorepo/multi-project @@ -39,12 +173,48 @@ function targetProvidersArrays(ctx: MigrationContext): ArrayLiteralExpression[] if (!arr) continue; // Respect an app that already configured (zone or zoneless) change detection. if (arr.getElements().some((el) => ZONE_PROVIDER.test(el.getText()))) continue; + if (!loadsZoneJs(ctx, file.getFilePath())) continue; arrays.push(arr); } } return arrays; } +/** + * Prepend the provider to a `providers` array as text, matching the indentation + * of the element it goes in front of. ts-morph's `insertElement` re-indents from + * its own settings rather than the file's, which lands an 8-space line in a + * 4-space array - and these apps often have no Prettier for the post-run format + * pass to clean up after. + */ +function prependProvider(arr: ArrayLiteralExpression): void { + const file = arr.getSourceFile(); + const first = arr.getElements()[0]; + if (!first) { + arr.insertElement(0, `${PROVIDER}()`); + return; + } + const start = first.getStart(); + const text = file.getFullText(); + const indent = text.slice(text.lastIndexOf('\n', start - 1) + 1, start); + // Reuse the indentation only when that element starts its own line; a + // single-line array (`providers: [a, b]`) stays on one line. + file.insertText(start, /^[ \t]*$/.test(indent) ? `${PROVIDER}(),\n${indent}` : `${PROVIDER}(), `); +} + +function addProviderImport(ctx: MigrationContext, filePath: string): void { + const file = ctx.project.getSourceFileOrThrow(filePath); + // A type-only import is skipped rather than extended: adding the provider to + // `import type { ... }` elides it at compile time, so the call in the providers + // array becomes a ReferenceError. + const coreImport = file.getImportDeclaration((d) => d.getModuleSpecifierValue() === CORE_MODULE && !d.isTypeOnly()); + if (!coreImport) { + file.addImportDeclaration({ moduleSpecifier: CORE_MODULE, namedImports: [PROVIDER] }); + } else if (!coreImport.getNamedImports().some((n) => n.getName() === PROVIDER)) { + coreImport.addNamedImport(PROVIDER); + } +} + export const angularZoneless: Migration = { id: 'angular-zoneless', framework: 'angular', @@ -62,16 +232,16 @@ export const angularZoneless: Migration = { }, fix(ctx) { - for (const arr of targetProvidersArrays(ctx)) { - arr.insertElement(0, `${PROVIDER}()`); - - const file = arr.getSourceFile(); - const coreImport = file.getImportDeclaration((d) => d.getModuleSpecifierValue() === CORE_MODULE); - if (!coreImport) { - file.addImportDeclaration({ moduleSpecifier: CORE_MODULE, namedImports: [PROVIDER] }); - } else if (!coreImport.getNamedImports().some((n) => n.getName() === PROVIDER)) { - coreImport.addNamedImport(PROVIDER); - } + // A text insert forgets every node in that file, so the match list is + // re-queried after each edit rather than iterated. Each fixed array now + // holds a zone provider, so it drops out of the next query and the loop + // converges; the counter is a backstop against an edit that does not. + for (let remaining = targetProvidersArrays(ctx).length; remaining > 0; remaining--) { + const [arr] = targetProvidersArrays(ctx); + if (!arr) break; + const filePath = arr.getSourceFile().getFilePath(); + prependProvider(arr); + addProviderImport(ctx, filePath); } }, }; diff --git a/packages/migrate/src/migrations/v9/core-browserslist-manual.ts b/packages/migrate/src/migrations/v9/core-browserslist-manual.ts new file mode 100644 index 00000000000..55cc04b991b --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-browserslist-manual.ts @@ -0,0 +1,56 @@ +import type { Migration } from '../../types.js'; +import { browserslistSources, entryBrowser } from '../../ast/browserslist.js'; +import { BROWSERS } from './core-browserslist.js'; + +/** + * Report-only companion to `core-browserslist`. That migration raises the + * versions of entries a project already has; this one names the browsers from + * the guide's block that the project has no entry for at all. + * + * Report-only on purpose: raising a floor keeps the same browsers, but adding + * one widens the support matrix, which changes the build's output. That is the + * developer's call, so the tool prints the entry to add rather than adding it. + * + * See https://ionicframework.com/docs/updating/9-0#browser-support + */ +/** + * Ionic browsers these entries never name, as `Name >=Floor` strings. + * + * Only meaningful for a list already written in the guide's named shape. A + * query-style list (`last 2 versions`, `> 0.5%`) names no browser at all, so + * every one of them would read as missing - that returns nothing instead. + */ +function missingEntries(entries: string[]): string[] { + const ionic = new Set(BROWSERS.map((b) => b.name.toLowerCase())); + const named = new Set(entries.map(entryBrowser).filter((name) => name !== undefined && ionic.has(name))); + if (named.size === 0) return []; + return BROWSERS.filter((b) => !named.has(b.name.toLowerCase())).map((b) => `${b.name} >=${b.floor}`); +} + +export const coreBrowserslistManual: Migration = { + id: 'core-browserslist-manual', + framework: 'core', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const sources = browserslistSources(ctx); + // A project can split its list across a `.browserslistrc` and a manifest, so + // a browser is only missing when no source names it. + const missing = missingEntries(sources.flatMap((source) => source.entries.map((entry) => entry.text))); + if (missing.length === 0) return []; + + // Non-empty only because `missingEntries` returns nothing for a list with no + // named browser, which includes the no-sources case. + const [first] = sources; + return [ + { + filePath: first.filePath, + line: first.line, + detail: `Ionic 9 supports browsers this list does not name, at least ${missing.join(', ')}. Add the entries you target`, + }, + ]; + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-browserslist.ts b/packages/migrate/src/migrations/v9/core-browserslist.ts index 9ddb7e4dd5d..7264049024c 100644 --- a/packages/migrate/src/migrations/v9/core-browserslist.ts +++ b/packages/migrate/src/migrations/v9/core-browserslist.ts @@ -1,3 +1,5 @@ +import { browserslistSources, raiseEntry, rewriteBrowserslists } from '../../ast/browserslist.js'; +import type { RaisedEntry } from '../../ast/browserslist.js'; import type { Finding, Migration } from '../../types.js'; /** @@ -10,40 +12,22 @@ import type { Finding, Migration } from '../../types.js'; * * See https://ionicframework.com/docs/updating/9-0#browser-support */ -/** Minimum version Ionic 9 supports, by browserslist browser name. */ -const FLOORS: Record = { - chrome: 89, - chromeandroid: 89, - firefox: 75, - edge: 89, - safari: 16, - ios: 16, -}; - -/** - * A `Name >=Version` entry, the shape the Ionic starters generate. The version - * is captured whole so raising `Safari >=15.4` writes `>=16`, not `>=16.4`. The - * optional `\r` keeps a CRLF checkout from matching nothing. - */ -const ENTRY = /^(\s*)([A-Za-z_]+)(\s*>=\s*)(\d+(?:\.\d+)*)(.*?)\r?$/; +/** The browsers Ionic 9 supports and their minimum versions, in guide order. */ +export const BROWSERS: { name: string; floor: number }[] = [ + { name: 'Chrome', floor: 89 }, + { name: 'ChromeAndroid', floor: 89 }, + { name: 'Firefox', floor: 75 }, + { name: 'Edge', floor: 89 }, + { name: 'Safari', floor: 16 }, + { name: 'iOS', floor: 16 }, +]; -const BROWSERSLIST_GLOBS = ['**/.browserslistrc', '**/browserslist']; +/** Minimum version Ionic 9 supports, keyed by lowercased browserslist name. */ +const FLOORS: Record = Object.fromEntries(BROWSERS.map((b) => [b.name.toLowerCase(), b.floor])); -/** - * The raised version of a browserslist line, or `undefined` when the line is not - * an entry this owns or is already at or above the floor. Shared by detect/fix - * so the report and the edit can never disagree. - */ -function raise(line: string): { name: string; from: string; to: number; line: string } | undefined { - const m = ENTRY.exec(line); - if (!m) return undefined; - const [, indent, name, op, version, rest] = m; - const floor = FLOORS[name.toLowerCase()]; - if (floor === undefined) return undefined; - // Compare on the major alone, so `Safari >=16.3` counts as meeting a floor of 16. - if (Number.parseInt(version, 10) >= floor) return undefined; - const crlf = line.endsWith('\r') ? '\r' : ''; - return { name, from: version, to: floor, line: `${indent}${name}${op}${floor}${rest}${crlf}` }; +/** The raised version of a line against Ionic 9's own floors. */ +function raise(line: string): RaisedEntry | undefined { + return raiseEntry(line, FLOORS); } export const coreBrowserslist: Migration = { @@ -56,32 +40,22 @@ export const coreBrowserslist: Migration = { detect(ctx) { const findings: Finding[] = []; - for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { - const text = ctx.readFile(filePath); - if (text === undefined) continue; - text.split('\n').forEach((line, i) => { - const raised = raise(line); + for (const source of browserslistSources(ctx)) { + for (const entry of source.entries) { + const raised = raise(entry.text); if (raised) { findings.push({ - filePath, - line: i + 1, + filePath: source.filePath, + line: entry.line, detail: `${raised.name} >=${raised.from} is below Ionic 9's floor. Raise it to >=${raised.to}`, }); } - }); + } } return findings; }, fix(ctx) { - for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { - const text = ctx.readFile(filePath); - if (text === undefined) continue; - const next = text - .split('\n') - .map((line) => raise(line)?.line ?? line) - .join('\n'); - if (next !== text) ctx.writeFile(filePath, next); - } + rewriteBrowserslists(ctx, (entry) => raise(entry)?.line ?? entry); }, }; diff --git a/packages/migrate/src/migrations/v9/core-capacitor.ts b/packages/migrate/src/migrations/v9/core-capacitor.ts index aad1f2ca9d4..a73c7f24894 100644 --- a/packages/migrate/src/migrations/v9/core-capacitor.ts +++ b/packages/migrate/src/migrations/v9/core-capacitor.ts @@ -3,13 +3,19 @@ import { findDependency, readPackageJson } from '../../ast/package-json.js'; import type { Migration } from '../../types.js'; /** - * Ionic 9's `isCapacitorNative` relies solely on `Capacitor.isNativePlatform()`, - * added in Capacitor 3. Report-only: the fix is a Capacitor upgrade. + * Ionic 9 supports Capacitor 7 and later, and its `isCapacitorNative` relies + * solely on `Capacitor.isNativePlatform()`, added in Capacitor 3. Report-only: + * the fix is a Capacitor upgrade, which touches the native projects too. + * + * Below 3 the app stops being detected as native at all, which is the sharper of + * the two cases. * * See https://ionicframework.com/docs/updating/9-0#capacitor */ +/** The oldest Capacitor Ionic 9 supports. */ +const MIN_CAPACITOR = 7; /** The first Capacitor with `isNativePlatform()`. */ -const MIN_CAPACITOR = 3; +const NATIVE_DETECTION_CAPACITOR = 3; const CAPACITOR_CORE = '@capacitor/core'; export const coreCapacitor: Migration = { @@ -31,14 +37,12 @@ export const coreCapacitor: Migration = { const major = parseMajor(dep.range); if (major === undefined || major >= MIN_CAPACITOR) return []; - return [ - { - filePath: 'package.json', - line: 1, - detail: - `Capacitor ${major} is no longer detected as a native platform. ` + - `isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web. Upgrade to Capacitor 7 or later`, - }, - ]; + const detail = + major < NATIVE_DETECTION_CAPACITOR + ? `Capacitor ${major} is no longer detected as a native platform. ` + + `isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web. Upgrade to Capacitor ${MIN_CAPACITOR} or later` + : `Capacitor ${major} is not supported by Ionic 9. Upgrade to Capacitor ${MIN_CAPACITOR} or later`; + + return [{ filePath: 'package.json', line: 1, detail }]; }, }; diff --git a/packages/migrate/src/types.ts b/packages/migrate/src/types.ts index 47a9ae60076..1c5c53b49fe 100644 --- a/packages/migrate/src/types.ts +++ b/packages/migrate/src/types.ts @@ -24,8 +24,8 @@ export type Framework = 'angular' | 'react' | 'vue' | 'core'; /** * Status of a migration. `experimental` migrations are skipped unless the user - * explicitly opts in, letting us ship transforms for changes that are not yet - * settled. + * explicitly opts in. Used for a transform that is not yet settled, or one whose + * consequence is the developer's call. */ export type MigrationStatus = 'stable' | 'experimental'; diff --git a/packages/migrate/test/angular-browser-policy-manual.test.ts b/packages/migrate/test/angular-browser-policy-manual.test.ts new file mode 100644 index 00000000000..a94779d88c2 --- /dev/null +++ b/packages/migrate/test/angular-browser-policy-manual.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { angularBrowserPolicyManual as migration } from '../src/migrations/v9/angular-browser-policy-manual.js'; +import { ANGULAR_20_POLICY, angularProject, fakeBrowserslist } from './helpers/angular-policy.js'; + +describe('angular-browser-policy-manual', () => { + it('names the version to raise an entry to, read from the installed Angular', () => { + const browserslist = fakeBrowserslist(['chrome 108', 'chrome 107', 'safari 16.0']); + const ctx = createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^20.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\nSafari >=16\n`, + 'node_modules/@angular/build/package.json': '{ "name": "@angular/build" }', + 'node_modules/@angular/build/.browserslistrc': ANGULAR_20_POLICY, + }, + '/app', + { browserslist } + ); + + const findings = migration.detect(ctx); + + // the static file is parsed into queries, with its comment and blank line dropped + expect(browserslist.queries).toEqual([ + ['Chrome >= 107', 'ChromeAndroid >= 107', 'Edge >= 107', 'Firefox >= 104', 'Safari >= 16', 'iOS >= 16'], + ]); + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].line).toBe(1); + expect(findings[0].detail).toContain('Chrome >=89'); + expect(findings[0].detail).toContain('>=107'); + // Safari >=16 already meets the policy, so it is not reported + expect(findings[0].detail).not.toContain('Safari'); + }); + + it('resolves the rolling baseline policy Angular 21+ ships instead of a file', () => { + const ctx = angularProject({ + major: 22, + files: { '.browserslistrc': `Safari >=16.1\n` }, + resolved: ['chrome 111', 'safari 16.4', 'ios_saf 16.6-16.7'], + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain("Angular 22's browser policy"); + // a decimal floor, and not confused with the 16.6 lower bound of the iOS range + expect(findings[0].detail).toContain('>=16.4'); + }); + + it('ignores the Android browsers, which caniuse tracks only at their latest', () => { + const ctx = angularProject({ + major: 22, + files: { '.browserslistrc': `ChromeAndroid >=89\nFirefoxAndroid >=89\n` }, + resolved: ['chrome 111', 'and_chr 149', 'firefox 112', 'and_ff 151'], + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('finds @angular/build nested under the devkit builder', () => { + // An app on @angular-devkit/build-angular has no top-level @angular/build, + // which is the layout the Ionic conference app ships. + const ctx = angularProject({ + major: 22, + files: { + '.browserslistrc': `Chrome >=107\n`, + 'node_modules/@angular-devkit/build-angular/package.json': '{ "name": "@angular-devkit/build-angular" }', + }, + resolved: ['chrome 111'], + buildDir: 'node_modules/@angular-devkit/build-angular/node_modules/@angular/build', + }); + + expect(migration.detect(ctx)[0]?.detail).toContain('>=111'); + }); + + it('falls back to naming the policy when dependencies are not installed', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('own browser support policy'); + }); + + it('says nothing on an Angular that enforces no policy of its own', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=79\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('says nothing when the app declares no browserslist of its own', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/angular-browser-policy.test.ts b/packages/migrate/test/angular-browser-policy.test.ts new file mode 100644 index 00000000000..54f5c84fb5d --- /dev/null +++ b/packages/migrate/test/angular-browser-policy.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { allMigrations } from '../src/migrations/index.js'; +import { selectMigrations } from '../src/registry.js'; +import { angularBrowserPolicy as migration } from '../src/migrations/v9/angular-browser-policy.js'; +import { angularBrowserPolicyManual } from '../src/migrations/v9/angular-browser-policy-manual.js'; +import { angularProject } from './helpers/angular-policy.js'; + +/** An Angular 22 project with the policy installed and resolvable. */ +function project(files: Record = {}) { + return angularProject({ + major: 22, + files, + resolved: ['chrome 111', 'firefox 112', 'safari 16.4', 'ios_saf 16.4'], + }); +} + +describe('angular-browser-policy', () => { + it("raises entries to Angular's own floors, including decimal ones", () => { + const ctx = project({ '.browserslistrc': `Chrome >=89\nSafari >=16\niOS >=16\n` }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=111\nSafari >=16.4\niOS >=16.4\n`); + }); + + it('reports each entry it would raise, with its file and line', () => { + const ctx = project({ '.browserslistrc': `Chrome >=89\nSafari >=16.4\n` }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].line).toBe(1); + expect(findings[0].detail).toBe('Chrome >=89 -> >=111 (Angular 22 browser policy)'); + }); + + it('raises the package.json field the Angular starters generate', () => { + const ctx = project({ + 'package.json': `${JSON.stringify( + { dependencies: { '@angular/core': '^22.0.0' }, browserslist: ['Chrome >=107', 'Safari >=16.1'] }, + null, + 2 + )}\n`, + }); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual(['Chrome >=111', 'Safari >=16.4']); + }); + + it('raises the env-keyed shape without flattening it', () => { + const ctx = project({ + 'package.json': `${JSON.stringify( + { + dependencies: { '@angular/core': '^22.0.0' }, + browserslist: { production: ['Chrome >=89', 'last 2 versions'], development: ['Chrome >=89'] }, + }, + null, + 2 + )}\n`, + }); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual({ + production: ['Chrome >=111', 'last 2 versions'], + development: ['Chrome >=111'], + }); + }); + + it('leaves a browser Angular policy does not name alone', () => { + const ctx = project({ '.browserslistrc': `Samsung >=15\nnot dead\n` }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Samsung >=15\nnot dead\n`); + }); + + it('writes nothing when the policy cannot be resolved', () => { + // Writing against empty floors is a silent no-op at best and wrong versions at worst. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('writes nothing when the project browserslist throws on the policy query', () => { + const ctx = createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + 'node_modules/@angular/build/package.json': '{ "name": "@angular/build" }', + 'node_modules/@angular/build/src/utils/supported-browsers.js': `const BASELINE_DATE = '2025-10-20';\n`, + }, + '/app', + { + browserslist: () => { + throw new Error('Unknown browser query'); + }, + } + ); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('writes nothing on an Angular that enforces no policy of its own', () => { + const ctx = angularProject({ major: 19, files: { '.browserslistrc': `Chrome >=89\n` }, resolved: ['chrome 111'] }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('is experimental, so a default run only gets the report', () => { + const selected = selectMigrations(allMigrations, { + fromMajor: 8, + toMajor: 9, + frameworks: ['angular'], + }).map((m) => m.id); + + expect(selected).toContain('angular-browser-policy-manual'); + expect(selected).not.toContain('angular-browser-policy'); + }); + + it('runs before the report under --experimental, which then goes quiet', () => { + // Both are selected with the opt-in, so the report must not repeat what the + // fix just applied. + const ctx = project({ '.browserslistrc': `Chrome >=89\n` }); + const selected = selectMigrations(allMigrations, { + fromMajor: 8, + toMajor: 9, + frameworks: ['angular'], + includeExperimental: true, + }).map((m) => m.id); + + expect(selected.indexOf('angular-browser-policy')).toBeLessThan(selected.indexOf('angular-browser-policy-manual')); + + migration.fix!(ctx); + + expect(angularBrowserPolicyManual.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/angular-zoneless-manual.test.ts b/packages/migrate/test/angular-zoneless-manual.test.ts index e1321f5018b..7b406b6138a 100644 --- a/packages/migrate/test/angular-zoneless-manual.test.ts +++ b/packages/migrate/test/angular-zoneless-manual.test.ts @@ -9,6 +9,7 @@ describe('angular-zoneless-manual', () => { 'src/main.ts': `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + `platformBrowserDynamic().bootstrapModule(AppModule).catch((e) => console.log(e));\n`, + 'src/polyfills.ts': `import 'zone.js';\n`, }); const findings = migration.detect(ctx); @@ -18,6 +19,16 @@ describe('angular-zoneless-manual', () => { expect(findings[0].detail).toContain('applicationProviders'); }); + it('does not flag an NgModule app that never loaded Zone.js', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': + `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + + `platformBrowserDynamic().bootstrapModule(AppModule).catch((e) => console.log(e));\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + it('does not flag a standalone bootstrap (handled by the auto-fix)', () => { const ctx = createInMemoryContext({ 'src/main.ts': `bootstrapApplication(AppComponent, { providers: [] });\n`, diff --git a/packages/migrate/test/angular-zoneless.test.ts b/packages/migrate/test/angular-zoneless.test.ts index 4f066cb4a6d..411bf013347 100644 --- a/packages/migrate/test/angular-zoneless.test.ts +++ b/packages/migrate/test/angular-zoneless.test.ts @@ -10,8 +10,11 @@ const STANDALONE_MAIN = ` providers: [provideIonicAngular()],\n` + `});\n`; +/** A polyfills file that loads Zone.js, as every pre-Angular-21 app has. */ +const ZONE_POLYFILLS = `import 'zone.js';\n`; + function withMain(text: string) { - const ctx = createInMemoryContext({ 'src/main.ts': text }); + const ctx = createInMemoryContext({ 'src/main.ts': text, 'src/polyfills.ts': ZONE_POLYFILLS }); const read = () => ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText(); return { ctx, read }; } @@ -61,7 +64,9 @@ describe('angular-zoneless', () => { it('fixes every standalone bootstrap in a multi-project workspace', () => { const ctx = createInMemoryContext({ 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/a/src/polyfills.ts': ZONE_POLYFILLS, 'apps/b/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/polyfills.ts': ZONE_POLYFILLS, }); expect(migration.detect(ctx)).toHaveLength(2); @@ -75,6 +80,202 @@ describe('angular-zoneless', () => { } }); + it('leaves an app that does not load Zone.js alone', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/polyfills.ts': `// This app runs zoneless, so Zone.js is not imported here.\n`, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['src/polyfills.ts'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + + migration.fix!(ctx); + + expect(ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText()).toBe(STANDALONE_MAIN); + }); + + it('fixes an app whose Zone.js is loaded by an angular.json polyfills entry', () => { + // The Angular CLI's current scaffold has no polyfills file: `zone.js` is + // listed straight in the build options, so no source file imports it. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['zone.js'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toHaveLength(1); + + migration.fix!(ctx); + + expect(ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText()).toContain( + 'provideZoneChangeDetection()' + ); + }); + + it('does not count a commented-out zone.js import as loading Zone.js', () => { + // The CLI scaffolds this exact commented line into `environments/environment.ts`, + // so a text scan would read every zoneless app as a Zone.js app. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/environments/environment.ts': + `// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.\n` + + `export const environment = { production: false };\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('matches the indentation of the providers it is inserted in front of', () => { + const { ctx, read } = withMain( + `import { bootstrapApplication } from '@angular/platform-browser';\n` + + `import { provideIonicAngular } from '@ionic/angular';\n\n` + + `bootstrapApplication(AppComponent, {\n` + + ` providers: [\n` + + ` { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },\n` + + ` provideIonicAngular(),\n` + + ` ],\n` + + `});\n` + ); + + migration.fix!(ctx); + const lines = read().split('\n'); + + expect(lines).toContain(' provideZoneChangeDetection(),'); + // and the element it was inserted in front of keeps its own indentation + expect(lines).toContain(' { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },'); + }); + + it('keeps a single-line providers array on one line', () => { + const { ctx, read } = withMain(STANDALONE_MAIN); + + migration.fix!(ctx); + + expect(read()).toContain('providers: [provideZoneChangeDetection(), provideIonicAngular()]'); + }); + + it('leaves the zoneless app alone in a workspace where a sibling keeps Zone.js', () => { + const ctx = createInMemoryContext({ + 'angular.json': JSON.stringify({ + projects: { + a: { root: 'apps/a', architect: { build: { options: { polyfills: ['zone.js'] } } } }, + b: { root: 'apps/b', architect: { build: { options: { polyfills: [] } } } }, + }, + }), + 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/main.ts': STANDALONE_MAIN, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/a/src/main.ts']); + + migration.fix!(ctx); + + const read = (path: string) => ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/${path}`).getFullText(); + expect(read('apps/a/src/main.ts')).toContain('provideZoneChangeDetection()'); + expect(read('apps/b/src/main.ts')).toBe(STANDALONE_MAIN); + }); + + it('scopes a polyfills import to its own project in a workspace', () => { + const ctx = createInMemoryContext({ + 'angular.json': JSON.stringify({ + projects: { + a: { root: 'apps/a', architect: { build: { options: { polyfills: ['apps/a/src/polyfills.ts'] } } } }, + b: { root: 'apps/b', architect: { build: { options: { polyfills: [] } } } }, + }, + }), + 'apps/a/src/polyfills.ts': ZONE_POLYFILLS, + 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/main.ts': STANDALONE_MAIN, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/a/src/main.ts']); + }); + + it('does not treat a Zone.js test setup as an app that bootstraps with it', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/test.ts': `import 'zone.js/testing';\n`, + 'angular.json': JSON.stringify({ + projects: { + app: { + architect: { + build: { options: { polyfills: ['src/polyfills.ts'] } }, + test: { options: { polyfills: ['zone.js', 'zone.js/testing'] } }, + }, + }, + }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('adds the provider to an empty providers array', () => { + const { ctx, read } = withMain( + `import { bootstrapApplication } from '@angular/platform-browser';\n` + + `bootstrapApplication(AppComponent, { providers: [] });\n` + ); + + migration.fix!(ctx); + + expect(read()).toContain('provideZoneChangeDetection()'); + }); + + it('reads a project root spelled as "." the same as an empty one', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ + projects: { app: { root: '.', architect: { build: { options: { polyfills: ['zone.js'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toHaveLength(1); + }); + + it('does not read a nested project polyfills as the outer project own', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ projects: { app: { root: '' } } }), + 'projects/admin/project.json': JSON.stringify({ targets: { build: { options: { polyfills: [] } } } }), + 'projects/admin/src/polyfills.ts': ZONE_POLYFILLS, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('ignores a zone.js import outside the polyfills the build loads', () => { + // A zoneless app can sit in a repo alongside code that still uses Zone.js, + // and adding the provider on the strength of that would break its bootstrap. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/polyfills.ts': `// zoneless\n`, + 'functions/legacy/setup.ts': ZONE_POLYFILLS, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['src/polyfills.ts'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('does not add the provider to a type-only @angular/core import', () => { + // Extending `import type { ... }` elides the provider at compile time, so the + // call in the providers array becomes a ReferenceError. + const { ctx, read } = withMain( + `import type { Provider } from '@angular/core';\n` + + `import { bootstrapApplication } from '@angular/platform-browser';\n\n` + + `bootstrapApplication(AppComponent, { providers: [] });\n` + ); + + migration.fix!(ctx); + const out = read(); + + expect(out).not.toMatch(/import type \{[^}]*provideZoneChangeDetection/); + expect(out).toMatch(/import \{ provideZoneChangeDetection \} from ['"]@angular\/core['"]/); + }); + it('ignores NgModule bootstrap (out of scope)', () => { const { ctx } = withMain( `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + diff --git a/packages/migrate/test/context.test.ts b/packages/migrate/test/context.test.ts index 25b6805d39d..138340ce184 100644 --- a/packages/migrate/test/context.test.ts +++ b/packages/migrate/test/context.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import { createInMemoryContext } from '../src/context.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createDiskContext, createInMemoryContext } from '../src/context.js'; + +const dirs: string[] = []; + +afterEach(() => { + let dir: string | undefined; + while ((dir = dirs.pop())) rmSync(dir, { recursive: true, force: true }); +}); describe('context.glob', () => { it('excludes native (ios/android) and build output directories at the project root', () => { @@ -29,6 +40,55 @@ describe('context.glob', () => { }); }); +describe('context.requireFromProject', () => { + it('returns undefined for a package the project does not have installed', () => { + // Migrations that read a tool's own config (e.g. Angular's browser policy) + // must degrade to report-only rather than throw on a fresh clone. + const ctx = createInMemoryContext({ 'package.json': '{}' }); + + expect(ctx.requireFromProject('browserslist')).toBeUndefined(); + }); + + it('returns undefined rather than throwing on a real project without node_modules', () => { + // The in-memory stub can't exercise `createRequire`, which is the part that + // throws, and every migration reading a tool's config depends on it not to. + const dir = mkdtempSync(join(tmpdir(), 'ionic-migrate-ctx-')); + dirs.push(dir); + writeFileSync(join(dir, 'package.json'), '{ "name": "app" }'); + + const ctx = createDiskContext(dir); + + expect(ctx.requireFromProject('definitely-not-installed')).toBeUndefined(); + expect(ctx.resolveFromProject('definitely-not-installed')).toBeUndefined(); + }); + + it('reads a package hoisted above the project, through the path it resolves to', () => { + // A workspace installs shared tooling at its root, so the resolved path sits + // outside the project and cannot be read as a project-relative one. + const root = mkdtempSync(join(tmpdir(), 'ionic-migrate-ws-')); + dirs.push(root); + mkdirSync(join(root, 'node_modules/@angular/build'), { recursive: true }); + writeFileSync(join(root, 'node_modules/@angular/build/package.json'), '{ "name": "@angular/build" }'); + writeFileSync(join(root, 'node_modules/@angular/build/.browserslistrc'), 'Chrome >= 107\n'); + mkdirSync(join(root, 'apps/web'), { recursive: true }); + writeFileSync(join(root, 'apps/web/package.json'), '{ "name": "web" }'); + + const ctx = createDiskContext(join(root, 'apps/web')); + const manifest = ctx.resolveFromProject('@angular/build/package.json'); + + expect(manifest).toBeDefined(); + expect(ctx.readFile(`${manifest!.slice(0, manifest!.lastIndexOf('/'))}/.browserslistrc`)).toBe('Chrome >= 107\n'); + }); + + it('returns a stubbed package so migrations can be tested without node_modules', () => { + const ctx = createInMemoryContext({ 'package.json': '{}' }, '/app', { browserslist: () => ['chrome 111'] }); + + const browserslist = ctx.requireFromProject<(q: string) => string[]>('browserslist'); + + expect(browserslist?.('anything')).toEqual(['chrome 111']); + }); +}); + describe('context.touchedFiles', () => { it('tracks writeFile edits', () => { const ctx = createInMemoryContext({ 'a.scss': 'x' }); diff --git a/packages/migrate/test/core-browserslist-manual.test.ts b/packages/migrate/test/core-browserslist-manual.test.ts new file mode 100644 index 00000000000..febbfcaf5c2 --- /dev/null +++ b/packages/migrate/test/core-browserslist-manual.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreBrowserslistManual as migration } from '../src/migrations/v9/core-browserslist-manual.js'; + +describe('core-browserslist-manual', () => { + it('reports a browser missing from an otherwise-named list', () => { + // The guide's block lists six browsers; a list naming five silently drops one. + const ctx = createInMemoryContext({ + '.browserslistrc': `Chrome >=107\nFirefox >=106\nEdge >=107\nSafari >=16.1\niOS >=16.1\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].detail).toContain('ChromeAndroid >=89'); + }); + + it('treats a list split across a file and the manifest as one list', () => { + const ctx = createInMemoryContext({ + '.browserslistrc': `Chrome >=107\nChromeAndroid >=107\nEdge >=107\n`, + 'package.json': `${JSON.stringify( + { name: 'app', browserslist: ['Firefox >=106', 'Safari >=16.1', 'iOS >=16.1'] }, + null, + 2 + )}\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('reports against the manifest when that is where the list lives', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify({ name: 'app', browserslist: ['Chrome >=107'] }, null, 2)}\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('package.json'); + expect(findings[0].line).toBe(3); + }); + + it('does not report missing browsers for a list of browsers Ionic does not name', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `Samsung >=15\nOpera >=90\n` }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('does not report missing browsers for a query-style list', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `last 2 versions\nnot dead\n` }); + + expect(migration.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-browserslist.test.ts b/packages/migrate/test/core-browserslist.test.ts index a4dcad35406..1bc99fb7ddd 100644 --- a/packages/migrate/test/core-browserslist.test.ts +++ b/packages/migrate/test/core-browserslist.test.ts @@ -64,6 +64,55 @@ describe('core-browserslist', () => { expect(ctx.readFile('.browserslistrc')).toBe(source); }); + it('raises a stale entry declared in package.json', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify( + { name: 'app', devDependencies: { browserslist: '^4.24.0' }, browserslist: ['Chrome >=79', 'Safari >=14'] }, + null, + 2 + )}\n`, + }); + + // located at their own lines, not at the same-named devDependency above them + expect(migration.detect(ctx).map((f) => `${f.filePath}:${f.line}`)).toEqual(['package.json:7', 'package.json:8']); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual(['Chrome >=89', 'Safari >=16']); + }); + + it('raises the same entry under two environment keys, reporting each line', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify( + { name: 'app', browserslist: { production: ['Chrome >=79'], development: ['Chrome >=79'] } }, + null, + 2 + )}\n`, + }); + + expect(migration.detect(ctx).map((f) => f.line)).toEqual([5, 8]); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual({ + production: ['Chrome >=89'], + development: ['Chrome >=89'], + }); + }); + + it('raises a workspace app manifest, not just the root one', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ name: 'workspace' }, null, 2), + 'apps/web/package.json': `${JSON.stringify({ name: 'web', browserslist: ['Chrome >=79'] }, null, 2)}\n`, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/web/package.json']); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('apps/web/package.json')!).browserslist).toEqual(['Chrome >=89']); + }); + it('reports the file and line of each stale entry', () => { const ctx = createInMemoryContext({ '.browserslistrc': `# browsers\nChrome >=79\nSafari >=14\n` }); diff --git a/packages/migrate/test/core-capacitor.test.ts b/packages/migrate/test/core-capacitor.test.ts index 96d1f849154..f81d582c41b 100644 --- a/packages/migrate/test/core-capacitor.test.ts +++ b/packages/migrate/test/core-capacitor.test.ts @@ -15,6 +15,19 @@ describe('core-capacitor', () => { expect(findings[0].detail).toContain('Capacitor 2'); }); + it('flags a Capacitor below the minimum Ionic 9 supports', () => { + // Native detection still works on 6, so this gets the softer of the two messages. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^6.0.0' } }, null, 2), + }); + + const findings = coreCapacitor.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('Capacitor 7'); + expect(findings[0].detail).not.toContain('isPlatform'); + }); + it('says nothing about a supported Capacitor', () => { const ctx = createInMemoryContext({ 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^7.0.0' } }, null, 2), diff --git a/packages/migrate/test/helpers/angular-policy.ts b/packages/migrate/test/helpers/angular-policy.ts new file mode 100644 index 00000000000..df8ef72de53 --- /dev/null +++ b/packages/migrate/test/helpers/angular-policy.ts @@ -0,0 +1,46 @@ +import { createInMemoryContext } from '../../src/context.js'; +import type { MigrationContext } from '../../src/context.js'; + +/** Angular 20 ships its policy as a static browserslist file. */ +export const ANGULAR_20_POLICY = `# Angular's supported browsers\n\nChrome >= 107\nChromeAndroid >= 107\nEdge >= 107\nFirefox >= 104\nSafari >= 16\niOS >= 16\n`; + +/** Angular 21+ builds its policy from this constant instead. */ +export const ANGULAR_BASELINE = `const BASELINE_DATE = '2025-10-20';\n`; + +/** + * A stand-in for the project's `browserslist`, resolving a fixed policy and + * recording the queries it was handed. + */ +export function fakeBrowserslist(resolved: string[]) { + const queries: unknown[] = []; + return Object.assign( + (query: unknown) => { + queries.push(query); + return resolved; + }, + { queries } + ); +} + +/** An Angular project with `@angular/build` installed and its policy resolvable. */ +export function angularProject(options: { + major: number; + files: Record; + resolved: string[]; + /** Where the installed `@angular/build` sits, for the devkit-nested layout. */ + buildDir?: string; +}): MigrationContext { + // Only the default layout installs `@angular/build` at the top level. The + // devkit-nested one must not, or the test stops discriminating. + const buildDir = options.buildDir ?? 'node_modules/@angular/build'; + return createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': `^${options.major}.0.0` } }, null, 2), + ...(options.buildDir === undefined ? { 'node_modules/@angular/build/package.json': '{}' } : {}), + [`${buildDir}/src/utils/supported-browsers.js`]: ANGULAR_BASELINE, + ...options.files, + }, + '/app', + { browserslist: fakeBrowserslist(options.resolved) } + ); +} diff --git a/packages/migrate/test/main.test.ts b/packages/migrate/test/main.test.ts index 88af50623aa..ebf12ecd52e 100644 --- a/packages/migrate/test/main.test.ts +++ b/packages/migrate/test/main.test.ts @@ -12,7 +12,8 @@ import { main } from '../src/main.js'; * `return 1` once left the type-checker and the whole suite green while CI * reported an unmigrated app as clean. * - * `--check` writes nothing and skips the git gate, so these need no repo. + * `--check` writes nothing and skips the git gate, so most need no repo. The + * formatter case writes, and passes `--force` to get past the gate instead. */ const dirs: string[] = []; @@ -99,6 +100,14 @@ describe('main', () => { expect(out).toContain('Nothing to do.'); }); + it('says the changed files went unformatted when the project has no Prettier', () => { + // The guide promises a Prettier pass, and a project without Prettier gets none. + const { code, out } = runCli(project('^8.4.0'), '--force', '--no-install'); + + expect(code).toBe(0); + expect(out).toContain('no Prettier'); + }); + it('throws on an unparseable --to rather than migrating to a guessed target', () => { expect(() => runCli(project('^8.4.0'), '--to', 'nine')).toThrow(/expected an integer/); }); From fedc1ca055deacfdb8a39b1d7958912de29529e9 Mon Sep 17 00:00:00 2001 From: Shane Date: Fri, 14 Aug 2026 16:07:31 -0700 Subject: [PATCH 4/4] Update v9.md Co-authored-by: Maria Hutt --- packages/migrate/docs/v9.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrate/docs/v9.md b/packages/migrate/docs/v9.md index 99952eb57db..af4a25f6134 100644 --- a/packages/migrate/docs/v9.md +++ b/packages/migrate/docs/v9.md @@ -60,7 +60,7 @@ the whole list for a vanilla app. | `ion-img` deprecation | report | | `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | report | | `@ionic/core` imports outside the new `exports` allowlist | report | -| Capacitor below the 7 Ionic 9 supports, and the Capacitor 2 native-detection break | report | +| Capacitor below version 7 (Ionic 9's minimum) and the Capacitor 2 native-detection change | report | | `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | report | | `label-placement="floating"` with slotted start/end content | report | | `ion-textarea` md min-height `56px` -> `72px` | report |