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
9 changes: 6 additions & 3 deletions packages/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,20 @@ 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

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:
whether it is auto-fixed, report-only, or experimental, and the changes left for
you to make by hand:

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

Expand Down
24 changes: 19 additions & 5 deletions packages/migrate/docs/v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Find your framework below, then read the
`@ionic/core`, so they apply on top of the framework-specific ones, and they are
the whole list for a vanilla app.

`experimental` migrations only run with `--experimental`.

### Angular

| Change | Mode |
Expand All @@ -28,6 +30,8 @@ the whole list for a vanilla app.
| Angular below the 18 floor | report |
| Angular 22's `OnPush` default and its Node floor | report |
| `@ionic/angular-toolkit` version bump | report |
| `browserslist` entries below Angular 20+'s own browser policy | report |
| `browserslist` entries raised to Angular's policy floors | experimental |

### React

Expand All @@ -51,11 +55,12 @@ the whole list for a vanilla app.
| `@ionic/core` package bump | auto |
| `autocorrect="off"` on `ion-input`/`ion-searchbar` | auto |
| `browserslist` entries raised to the v9 browser floors | auto |
| Browsers from the v9 list a `browserslist` doesn't name | report |
| 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 |
| 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 |
Expand All @@ -80,13 +85,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.
188 changes: 188 additions & 0 deletions packages/migrate/src/ast/browserslist.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>): 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);
}
}
}
9 changes: 6 additions & 3 deletions packages/migrate/src/ast/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading