Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 24 additions & 48 deletions packages/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,44 +41,13 @@ 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 |
| 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 |
| `<Route exact>` removal, `component={X}` -> `element={<X />}` | React | auto |
| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, `history` prop, regex paths | React | report |
| `@ionic/vue` + Vue Router 5 + Vue 3.5 bumps | Vue | auto |
| `next()` in navigation guards | Vue | report |
| `autocorrect="off"` on `ion-input`/`ion-searchbar` | 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.
### Coverage

Each major upgrade has its own page, listing every change the tool covers,
whether it is auto-fixed or report-only, and the changes left for you to make by
hand:

- [v8 to v9](./docs/v9.md)

## How it works

Expand All @@ -88,7 +57,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.
Expand All @@ -99,16 +68,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`.
- `ion-img` is report-only. It's a deprecation, not a v9 break.
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.
- Stylesheet scanning covers `.css` and `.scss` files. Styles inlined in a
component decorator's `styles` array aren't read.

## Extending

Expand All @@ -119,6 +90,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<major>.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
Expand Down
92 changes: 92 additions & 0 deletions packages/migrate/docs/v9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Migrating v8 to v9

This page covers what `npx @ionic/migrate` handles 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

Find your framework below, then read the
[all frameworks](#all-frameworks) section as well. Those changes come from
`@ionic/core`, so they apply on top of the framework-specific ones, and they are
the whole list for a vanilla app.

### Angular

| Change | Mode |
| --- | --- |
| `@ionic/angular` -> `@ionic/angular/lazy`, `/standalone` -> `@ionic/angular` | auto |
| `@ionic/angular` package bump | auto |
| `moduleResolution: "node"` -> `"bundler"` in `tsconfig*.json` | auto |
| TypeScript raised to the 5.4 floor | auto |
| CSS `~` prefix removal in `@ionic/angular` imports | auto |
| Add `provideZoneChangeDetection()` to a standalone bootstrap (keep Zone.js) | auto |
| NgModule bootstrap zone provider | report |
| `IonicModule` deprecation (`provideIonicAngular()`) | report |
| Angular below the 18 floor | report |
| Angular 22's `OnPush` default and its Node floor | report |
| `@ionic/angular-toolkit` version bump | report |

### React

| Change | Mode |
| --- | --- |
| `@ionic/react` + React 18 + React Router v6 bumps, drop `@types/react-router*` | auto |
| `<Route exact>` removal, `component={X}` -> `element={<X />}` | auto |
| React Router v6: removed imports, `IonRedirect`, `render`/non-identifier `component`, route children, `history` prop, regex paths | report |

### Vue

| Change | Mode |
| --- | --- |
| `@ionic/vue` + Vue Router 5 + Vue 3.5 bumps | auto |
| `next()` in navigation guards | report |

### All frameworks

| Change | Mode |
| --- | --- |
| `@ionic/core` package bump | auto |
| `autocorrect="off"` on `ion-input`/`ion-searchbar` | auto |
| `browserslist` entries raised to the v9 browser floors | auto |
| Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | report |
| `ion-img` deprecation | report |
| `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | report |
| `@ionic/core` imports outside the new `exports` allowlist | report |
| Capacitor 2 no longer detected as a native platform | 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 |
| `ion-modal` `handleBehavior` default (`"none"` -> `"cycle"`) | report |
| `ion-select` `ionChange` firing and the action sheet `selected` role | report |
| `swipeBackEnabled` config, now read once at outlet mount | 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.
31 changes: 24 additions & 7 deletions packages/migrate/src/ast/text-scan.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
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;
}

/** 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'];
2 changes: 1 addition & 1 deletion packages/migrate/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function createInMemoryContext(files: Record<string, string>, rootDir = '
manipulationSettings: MANIPULATION_SETTINGS,
});
const fs = project.getFileSystem();
// Write everything to the filesystem so glob/readFile see it, mirroring how
// Write everything to the filesystem so glob/readFile find it, mirroring how
// the disk context works, then load TS sources into ts-morph.
for (const [relPath, content] of Object.entries(files)) {
fs.writeFileSync(join(rootDir, relPath), content);
Expand Down
26 changes: 21 additions & 5 deletions packages/migrate/src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ export interface DetectedFramework {
major: number;
}

const FRAMEWORK_PACKAGES: Record<Exclude<Framework, 'core'>, 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 runs against the project. A framework app that pins `@ionic/core`
* directly detects both, and that pin has to reach v9 too.
*/
const FRAMEWORK_PACKAGES: Record<Framework, string> = {
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`. */
Expand All @@ -34,6 +41,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`.
Expand All @@ -54,10 +73,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<Framework, 'core'>,
string,
][]) {
for (const [framework, pkgName] of Object.entries(FRAMEWORK_PACKAGES) as [Framework, string][]) {
const range = deps[pkgName];
if (range === undefined) continue;
// Only a plain, bumpable semver range gates re-runs correctly. angular-deps
Expand Down
6 changes: 3 additions & 3 deletions packages/migrate/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/migrate/src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
Loading
Loading