diff --git a/README.md b/README.md index f083fb49d..788b8d2ab 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ export class App { [Upgrading from AngularFire 20? See the v21 upgrade guide.](docs/version-21-upgrade.md) +[Upgrading from AngularFire 21? See the v22 upgrade guide.](docs/version-22-upgrade.md) + ### Sample app The [`sample`](sample) folder contains a kitchen sink application that demonstrates use of the "modular" API, in a zoneless server-rendered application, with all the bells and whistles. diff --git a/docs/version-22-upgrade.md b/docs/version-22-upgrade.md new file mode 100644 index 000000000..159a1673c --- /dev/null +++ b/docs/version-22-upgrade.md @@ -0,0 +1,58 @@ +# Upgrading to the AngularFire 22 canary + +AngularFire 22 targets **Angular 22** and the **Firebase JS SDK v12**. Most of the upgrade is handled for you by `ng update`. + +## Run the update + +AngularFire 22 is published under the `canary` tag, a build of every merge to `main`, until a release candidate is published under `next`. From an app on Angular 21 and AngularFire 21, run: + +```bash +ng update @angular/core@22 @angular/cli@22 @angular/fire@canary +``` + +Include `@angular/fire@canary` in the same command as Angular. On its own, `ng update @angular/core@22 @angular/cli@22` refuses to run while AngularFire 21 is installed, because AngularFire 21 only accepts Angular 21. + +The AngularFire update runs a migration that: + +- **Adds** `firebase` **to your** `package.json` **if it is missing**, as `"firebase": "^12.18.0"`, and reinstalls. See the next section for why. +- **Aligns an existing** `firebase` **entry to** `^12.18.0` when it requests a version outside that range. + +## Your app now owns the Firebase SDK + +Through AngularFire 21, `@angular/fire` listed `firebase` under `dependencies`, so npm could install a second copy of the SDK under `node_modules/` whenever your app asked for a different version. The two copies reject each other's objects at runtime, with errors such as `Expected type '_Query', but it was: a custom _CollectionReference object` or `toDate is not a function`. + +AngularFire 22 lists `firebase` under `peerDependencies` instead. So your app should declare `firebase` in its `package.json` (on Yarn it must, since Yarn installs no peer dependencies). This gives you exactly one copy of `firebase`, and an incompatible version is refused at install time instead of failing at runtime. + +`ng update @angular/fire@canary` updates an older `firebase` entry in your `package.json` to `^12.18.0` when it requests a version outside that range. If your app doesn't list `firebase` in `package.json` the migration adds `"firebase": "^12.18.0"` and reinstalls. Verify with `npm ls firebase`, which should show exactly one copy. + +### If you install with `npm install` instead of `ng update` + +Add the dependency yourself: + +```bash +npm install @angular/fire@canary firebase@^12.18.0 +``` + +npm 7 and later install a missing peer dependency automatically, so `npm install @angular/fire@canary` alone also works, but it does not write `firebase` into your `package.json`. Declare it so that a later install cannot change which version you get. + +### If npm stops with `ERESOLVE` + +An app that requests a `firebase` version outside the range AngularFire 22 supports now fails to install with an `ERESOLVE` error. Update your `firebase` entry to a version inside the range given in the error message, then install again: + +```bash +npm install firebase@^12.18.0 +``` + +Do not use `--force` or `--legacy-peer-deps` to get past this message. Both install the older SDK next to AngularFire 22 and the runtime errors described above come back. If your project has `legacy-peer-deps=true` in its `.npmrc`, remove it before upgrading. + +### Yarn + +Yarn doesn't install peer dependencies. Add `firebase` to your own `package.json` as shown above, or run `ng update @angular/fire@canary`, which adds it. + +An older version installs anyway with a warning (`has incorrect peer dependency "firebase@^12.18.0"` on Yarn 1, `YN0060: INCOMPATIBLE_PEER_DEPENDENCY` on Yarn 2+), so treat that warning as an upgrade step you still have to do. + +### pnpm + +pnpm 8 and later install a missing `firebase` automatically. An older version installs anyway with a warning (`unmet peer firebase@^12.18.0: found 11.x` on pnpm 9 and 10, `Issues with peer dependencies found. Run "pnpm peers check" to list them.` on pnpm 11 and 12), and pnpm prints that warning only once, when it first resolves the tree, never on a later install from the committed lockfile. + +Set `strict-peer-dependencies=true` in `.npmrc` to make pnpm refuse the mismatch outright. diff --git a/src/ng-package.json b/src/ng-package.json index 0eb72fb12..9e2805084 100644 --- a/src/ng-package.json +++ b/src/ng-package.json @@ -7,6 +7,6 @@ "allowedNonPeerDependencies": [ "@angular-devkit/architect", "@angular-devkit/core", "@angular-devkit/schematics", "@schematics/angular", - "jsonc-parser", "firebase", "rxfire" + "jsonc-parser", "rxfire" ] } diff --git a/src/package.json b/src/package.json index 9f2537957..4f141d993 100644 --- a/src/package.json +++ b/src/package.json @@ -31,6 +31,7 @@ "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-browser": "^22.0.0", + "firebase": "^12.18.0", "rxjs": "~7.8.0", "firebase-tools": "^14.0.0 || ^15.0.0", "typescript": ">=6.0 <6.1" @@ -40,7 +41,6 @@ "typescript": { "optional": true } }, "dependencies": { - "firebase": "^12.18.0", "rxfire": "^6.2.0", "@angular-devkit/architect": ">= 0.2200.0 < 0.2300.0", "@angular-devkit/core": "^22.0.0", diff --git a/src/schematics/common.jasmine.ts b/src/schematics/common.jasmine.ts index c9209cb71..2a55eaa64 100644 --- a/src/schematics/common.jasmine.ts +++ b/src/schematics/common.jasmine.ts @@ -214,9 +214,9 @@ describe('alignFirebaseVersion', () => { expect(sectionIn(tree, 'devDependencies').firebase).toBe(firebaseVersionRange); }); - it('matches the firebase range the library actually ships with', () => { + it('matches the firebase range the library declares as its peer dependency', () => { const libraryManifest = JSON.parse(readFileSync('src/package.json', 'utf8')); - expect(libraryManifest.dependencies.firebase).toBe(firebaseVersionRange); + expect(libraryManifest.peerDependencies.firebase).toBe(firebaseVersionRange); }); it('is a no-op when run a second time', () => { diff --git a/src/schematics/common.ts b/src/schematics/common.ts index 4630bcfe9..6df7c8bb1 100644 --- a/src/schematics/common.ts +++ b/src/schematics/common.ts @@ -72,22 +72,20 @@ export const addDependencies = ( overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); }; -/* Must stay identical to `dependencies.firebase` in `src/package.json`: if the two drift, the - * alignment below can pin workspaces outside the range the library actually installs against, - * re-creating duplicate-SDK trees. */ +/* Must stay identical to `peerDependencies.firebase` in `src/package.json`: if the two drift, the + * alignment below writes a range the peer dependency then rejects. */ export const firebaseVersionRange = '^12.18.0'; /** * Aligns the workspace's `firebase` entry with the range `@angular/fire` requires. * - * `@angular/fire` bundles its own `firebase` dependency, so a workspace pinned to an older - * major never conflicts at install time; npm silently nests a second SDK copy under - * `@angular/fire`, and the two copies reject each other's objects at runtime (#3684, #3681, - * #3682). Rewriting the workspace's range before the install runs is the only point where - * that class of breakage can be headed off. + * `firebase` is a peer dependency of `@angular/fire`. Depending on the package manager and its + * settings, a workspace range outside it is refused at install time or installs a `firebase` + * the library was not built against, and a workspace that never declared `firebase` gets no + * entry written. Rewriting the entry before the install runs heads both off. * * Returns whether `package.json` was modified, so callers can skip scheduling an install - * when nothing changed (`ng update` re-runs the v21 migration on rc-to-stable updates). + * when nothing changed (`ng update` re-runs a migration on rc-to-stable updates). */ export const alignFirebaseVersion = ( host: Tree, @@ -119,8 +117,8 @@ export const alignFirebaseVersion = ( } catch (_) { context.logger.warn( `⚠️ The firebase version in your package.json (${declaredFirebaseVersion}) is not a semver ` + - `range, so it was left as-is; make sure it resolves inside ${firebaseVersionRange}, the ` + - 'range @angular/fire requires; a version outside it can leave the install with two copies of the firebase SDK.' + `range, so it was left as-is. Make sure it resolves inside ${firebaseVersionRange}, the ` + + 'range @angular/fire requires. A version outside it either fails to install or installs a firebase that @angular/fire was not built against.' ); continue; } @@ -128,8 +126,8 @@ export const alignFirebaseVersion = ( packageJson[section].firebase = firebaseVersionRange; context.logger.info( `Updated the firebase version in your package.json from ${declaredFirebaseVersion} to ` + - `${firebaseVersionRange}, the range @angular/fire requires; a workspace range outside it ` + - 'can leave the install with a second copy of the firebase SDK, which fails at runtime.' + `${firebaseVersionRange}, the range @angular/fire requires. A range outside it either fails ` + + 'to install or installs a firebase that @angular/fire was not built against.' ); changed = true; } diff --git a/src/schematics/migration.json b/src/schematics/migration.json index 2a2b926b0..4b99835c5 100644 --- a/src/schematics/migration.json +++ b/src/schematics/migration.json @@ -8,8 +8,13 @@ }, "migration-v21": { "version": "21.0.0", - "description": "Align the workspace's firebase dependency with the range @angular/fire 21 requires, and rewrite Vertex AI imports to Firebase AI Logic (getVertexAI callers keep the same backend, and a call that passed no location moves from us-central1 to global)", + "description": "Align the workspace's firebase dependency with the range @angular/fire requires, and rewrite Vertex AI imports to Firebase AI Logic (getVertexAI callers keep the same backend, and a call that passed no location moves from us-central1 to global)", "factory": "./update/v21#ngUpdate" + }, + "migration-v22": { + "version": "22.0.0", + "description": "Add firebase to the workspace's package.json when it is missing, now that @angular/fire lists it as a peer dependency, and align an existing entry with the range @angular/fire requires", + "factory": "./update/v22#ngUpdate" } } } \ No newline at end of file diff --git a/src/schematics/tsconfig.json b/src/schematics/tsconfig.json index 5c2536e7d..ca9d80392 100644 --- a/src/schematics/tsconfig.json +++ b/src/schematics/tsconfig.json @@ -29,5 +29,6 @@ "setup/index.ts", "update/v7/index.ts", "update/v21/index.ts", + "update/v22/index.ts", ] } \ No newline at end of file diff --git a/src/schematics/update/v22/index.jasmine.ts b/src/schematics/update/v22/index.jasmine.ts new file mode 100644 index 000000000..60a171eab --- /dev/null +++ b/src/schematics/update/v22/index.jasmine.ts @@ -0,0 +1,59 @@ +import { logging } from '@angular-devkit/core'; +import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import { firebaseVersionRange } from '../../common.js'; +import { ngUpdate } from './index.js'; +import 'jasmine'; + +const contextWithTaskSpy = () => { + const addTask = jasmine.createSpy('addTask'); + const context = { + logger: new logging.Logger('test'), + addTask, + } as unknown as SchematicContext; + return { context, addTask }; +}; + +const treeWithDependencies = (dependencies: Record) => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ name: 'test-app', dependencies }, null, 2)); + return tree; +}; + +const dependenciesIn = (tree: HostTree) => JSON.parse(tree.readText('package.json')).dependencies; + +describe('migration-v22 ngUpdate', () => { + + it('adds firebase when the workspace never declared it and schedules an install', () => { + const { context, addTask } = contextWithTaskSpy(); + const tree = treeWithDependencies({ '@angular/fire': '^22.0.0' }); + ngUpdate()(tree, context); + expect(dependenciesIn(tree).firebase).toBe(firebaseVersionRange); + expect(addTask).toHaveBeenCalledTimes(1); + }); + + it('aligns a firebase range outside the peer range and schedules an install', () => { + const { context, addTask } = contextWithTaskSpy(); + const tree = treeWithDependencies({ firebase: '^11.0.0' }); + ngUpdate()(tree, context); + expect(dependenciesIn(tree).firebase).toBe(firebaseVersionRange); + expect(addTask).toHaveBeenCalledTimes(1); + }); + + it('schedules nothing when the workspace is already aligned', () => { + const { context, addTask } = contextWithTaskSpy(); + const tree = treeWithDependencies({ firebase: firebaseVersionRange }); + ngUpdate()(tree, context); + expect(dependenciesIn(tree).firebase).toBe(firebaseVersionRange); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('is a no-op on the second run', () => { + const { context, addTask } = contextWithTaskSpy(); + const tree = treeWithDependencies({}); + ngUpdate()(tree, context); + ngUpdate()(tree, context); + expect(dependenciesIn(tree).firebase).toBe(firebaseVersionRange); + expect(addTask).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/src/schematics/update/v22/index.ts b/src/schematics/update/v22/index.ts new file mode 100644 index 000000000..0b0dd4b3a --- /dev/null +++ b/src/schematics/update/v22/index.ts @@ -0,0 +1,14 @@ +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks/index.js'; +import { alignFirebaseVersion } from '../../common.js'; + +/* `ng update` runs this migration again on every prerelease step of the same release (rc.0 to + * rc.1, rc.1 to 22.0.0), so it must change nothing when the workspace is already aligned. */ +export const ngUpdate = (): Rule => (host: Tree, context: SchematicContext) => { + /* `ng update` updates a `firebase` entry the workspace already has, but adds none to a workspace + * that has no `firebase` entry at all. Under a peer dependency that workspace needs one. */ + if (alignFirebaseVersion(host, context)) { + context.addTask(new NodePackageInstallTask()); + } + return host; +}; diff --git a/tools/build.ts b/tools/build.ts index c512f867e..01ceeea36 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -383,6 +383,7 @@ const schematicEntryPoints = [ ['setup', 'index'], ['update', 'v7', 'index'], ['update', 'v21', 'index'], + ['update', 'v22', 'index'], ]; const externalPackages = [