From 59ee99c7602901c3aa1e1e529e04b0f087798cac Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Mon, 24 Aug 2026 22:40:44 -0700 Subject: [PATCH 1/8] build(recipe-demo): add angularfire at latest canary Commit b83343d is the latest canary as of this writing. --- recipe-demo/package-lock.json | 85 ++++++++++++++++++++--------------- recipe-demo/package.json | 1 + 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/recipe-demo/package-lock.json b/recipe-demo/package-lock.json index e45181be2..bff093494 100644 --- a/recipe-demo/package-lock.json +++ b/recipe-demo/package-lock.json @@ -11,6 +11,7 @@ "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", + "@angular/fire": "21.0.0-rc.0-canary.b83343d", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/platform-server": "^21.2.0", @@ -263,7 +264,6 @@ "version": "0.2102.21", "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.21.tgz", "integrity": "sha512-WqITVviALevNpCQoI50e3YY9qNOQT+gqTow/4FhWsxlAJFMb5uyilFpVz2hGYWPgosF3OAKmT1h9r+Jm9S6SUQ==", - "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "21.2.21", @@ -282,7 +282,6 @@ "version": "21.2.21", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.21.tgz", "integrity": "sha512-xOr6mZ00M6hgM/xltAVkPVc/Yd1a/Wa0CGecV64JTITaVaSH8p8+wXe62Xavdr3pcc9cLKKUUB4mAup4k7Kkyw==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "8.18.0", @@ -310,7 +309,6 @@ "version": "21.2.21", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.21.tgz", "integrity": "sha512-LgTd/0CpyWSxMJLLk0X85F5RyDlJekssLlapU1C8fKqOUdWONYncTXYvZUQ+zbRAGXHcSZrst1Vm8qdXbo11Mg==", - "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "21.2.21", @@ -546,6 +544,42 @@ } } }, + "node_modules/@angular/fire": { + "version": "21.0.0-rc.0-canary.b83343d", + "resolved": "https://registry.npmjs.org/@angular/fire/-/fire-21.0.0-rc.0-canary.b83343d.tgz", + "integrity": "sha512-3IvRfjSEiISnUtt/UypXjq+YDfPVFqK8t1dfvtWGn8j68GQbV88tod26BaHM/hyi0iCzfSc65pQNXrgfb92Vzw==", + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": ">= 0.2100.0 < 0.2200.0", + "@angular-devkit/core": "^21.0.0", + "@angular-devkit/schematics": "^21.0.0", + "@schematics/angular": "^21.0.0", + "firebase": "^12.4.0", + "jsonc-parser": "^3.0.0", + "rxfire": "^6.2.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "firebase-tools": "^14.0.0 || ^15.0.0", + "rxjs": "~7.8.0", + "typescript": ">=5.8 <6.0" + }, + "peerDependenciesMeta": { + "@angular/platform-server": { + "optional": true + }, + "firebase-tools": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@angular/forms": { "version": "21.2.21", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.21.tgz", @@ -2645,7 +2679,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4420,7 +4453,6 @@ "version": "21.2.21", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.21.tgz", "integrity": "sha512-ZiR5CcDMBI+0TkP4WeazhJmu1SdIq81VvO9CbXEHBO9KQWTtuE0EQCnzQkpIo4Dyh6jDRpA6sA3gkh79vW4aNQ==", - "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "21.2.21", @@ -4732,7 +4764,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4749,7 +4780,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -4809,7 +4839,6 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5068,7 +5097,6 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -5088,7 +5116,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -5114,7 +5142,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" @@ -5130,7 +5157,6 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.20" @@ -5746,14 +5772,12 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, "funding": [ { "type": "github", @@ -5931,7 +5955,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6294,7 +6317,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6313,7 +6335,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6406,7 +6427,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -6433,7 +6453,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, "license": "MIT" }, "node_modules/jsonparse": { @@ -6539,7 +6558,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0", @@ -6645,7 +6663,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -6738,7 +6755,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7257,7 +7273,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" @@ -7273,7 +7288,6 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "dev": true, "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -7479,7 +7493,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7693,7 +7706,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -7723,7 +7736,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7733,7 +7745,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, "license": "MIT", "dependencies": { "onetime": "^7.0.0", @@ -7847,6 +7858,16 @@ "node": ">= 18" } }, + "node_modules/rxfire": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.2.0.tgz", + "integrity": "sha512-XSRdYjV6rZJUbUL2IpTqLtgnhNHDp9j2KSHZW04R+/ODKm8Ir2ag8I+kVZCq6j1NCclB4JMx60sgSDWcvDYR3g==", + "license": "Apache-2.0", + "peerDependencies": { + "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8096,7 +8117,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -8185,7 +8205,6 @@ "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" @@ -8273,7 +8292,6 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8286,7 +8304,6 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -8303,7 +8320,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -8424,7 +8440,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8818,7 +8834,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/recipe-demo/package.json b/recipe-demo/package.json index c78f2dc7b..be90c4f62 100644 --- a/recipe-demo/package.json +++ b/recipe-demo/package.json @@ -14,6 +14,7 @@ "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", + "@angular/fire": "21.0.0-rc.0-canary.b83343d", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/platform-server": "^21.2.0", From adc8c6dfd6ea79b297df4b7f8938beb0c51f3413 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 25 Aug 2026 01:34:02 -0700 Subject: [PATCH 2/8] refactor(recipe-demo): replace firebase tokens with angularfire providers The four hand-made injection tokens and their platform guards are gone, along with the file that declared them. AngularFire's providers take over: provideFirebaseApp once per platform, plus provideFirestore, provideAuth, provideAppCheck and provideAI. --- recipe-demo/src/app/app.config.client.ts | 38 +++++++++--------------- recipe-demo/src/app/app.config.server.ts | 33 ++++++++++---------- recipe-demo/src/app/app.config.ts | 10 +++---- recipe-demo/src/app/auth-store.ts | 5 ++-- recipe-demo/src/app/firebase-tokens.ts | 10 ------- recipe-demo/src/app/recipe-store.ts | 7 +++-- 6 files changed, 40 insertions(+), 63 deletions(-) delete mode 100644 recipe-demo/src/app/firebase-tokens.ts diff --git a/recipe-demo/src/app/app.config.client.ts b/recipe-demo/src/app/app.config.client.ts index b610bc802..c9d893865 100644 --- a/recipe-demo/src/app/app.config.client.ts +++ b/recipe-demo/src/app/app.config.client.ts @@ -5,32 +5,19 @@ import { mergeApplicationConfig, provideAppInitializer, } from '@angular/core'; -import { FirebaseApp, initializeApp } from 'firebase/app'; -import { AgentPlatformBackend, getAI } from 'firebase/ai'; -import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check'; -import { Auth, beforeAuthStateChanged, onIdTokenChanged, Unsubscribe } from 'firebase/auth'; +import { AgentPlatformBackend, getAI, provideAI } from '@angular/fire/ai'; +import { FirebaseApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; +import { initializeAppCheck, provideAppCheck, ReCaptchaV3Provider } from '@angular/fire/app-check'; +import { Auth, onIdTokenChanged, Unsubscribe } from '@angular/fire/auth'; +import { beforeAuthStateChanged } from 'firebase/auth'; import cookies from 'js-cookie'; import { appConfig } from './app.config'; import { firebaseConfig, recaptchaSiteKey } from './firebase-config'; -import { FIREBASE_AI, FIREBASE_APP, FIREBASE_AUTH } from './firebase-tokens'; - -function createFirebaseApp(): FirebaseApp { - if (location.hostname === 'localhost') { - // Set App Check debug flag before initializeAppCheck runs or the SDK mints no token. - Object.assign(globalThis, { FIREBASE_APPCHECK_DEBUG_TOKEN: true }); - } - const app = initializeApp(firebaseConfig); - initializeAppCheck(app, { - provider: new ReCaptchaV3Provider(recaptchaSiteKey), - isTokenAutoRefreshEnabled: true, - }); - return app; -} /** Mirrors the signed-in state into a __session cookie so the server can render as this user. */ function syncSessionCookie(): void { - const auth = inject(FIREBASE_AUTH); + const auth = inject(Auth); const destroyRef = inject(DestroyRef); // Refresh session cookie on startup/sign-in/sign-out/background token refresh. @@ -66,11 +53,14 @@ function syncBeforeAuthChange(auth: Auth): Unsubscribe { const clientConfig: ApplicationConfig = { providers: [ - { provide: FIREBASE_APP, useFactory: createFirebaseApp }, - { - provide: FIREBASE_AI, - useFactory: () => getAI(inject(FIREBASE_APP), { backend: new AgentPlatformBackend() }), - }, + provideFirebaseApp(() => initializeApp(firebaseConfig)), + provideAppCheck(() => + initializeAppCheck(inject(FirebaseApp), { + provider: new ReCaptchaV3Provider(recaptchaSiteKey), + isTokenAutoRefreshEnabled: true, + }), + ), + provideAI(() => getAI(inject(FirebaseApp), { backend: new AgentPlatformBackend() })), provideAppInitializer(syncSessionCookie), ], }; diff --git a/recipe-demo/src/app/app.config.server.ts b/recipe-demo/src/app/app.config.server.ts index 3475d59f3..7bc726cb7 100644 --- a/recipe-demo/src/app/app.config.server.ts +++ b/recipe-demo/src/app/app.config.server.ts @@ -6,13 +6,13 @@ import { mergeApplicationConfig, provideAppInitializer, } from '@angular/core'; +import { deleteApp, initializeServerApp, provideFirebaseApp } from '@angular/fire/app'; +import { Auth } from '@angular/fire/auth'; import { provideServerRendering, withRoutes } from '@angular/ssr'; -import { deleteApp, initializeServerApp } from 'firebase/app'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; import { firebaseConfig } from './firebase-config'; -import { FIREBASE_APP, FIREBASE_AUTH } from './firebase-tokens'; // REQUEST_CONTEXT is `unknown`. It contains an authIdToken property when user is signed-in. function hasAuthIdToken(context: unknown): context is { authIdToken: string } { @@ -25,24 +25,21 @@ function hasAuthIdToken(context: unknown): context is { authIdToken: string } { const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), - { - provide: FIREBASE_APP, - useFactory: () => { - // Pass REQUEST_CONTEXT's authIdToken to render personalized signed-in content. - const requestContext = inject(REQUEST_CONTEXT, { optional: true }); - const app = hasAuthIdToken(requestContext) - ? initializeServerApp(firebaseConfig, { authIdToken: requestContext.authIdToken }) - : initializeServerApp(firebaseConfig, {}); + provideFirebaseApp(() => { + // Pass REQUEST_CONTEXT's authIdToken to render personalized signed-in content. + const requestContext = inject(REQUEST_CONTEXT, { optional: true }); + const app = hasAuthIdToken(requestContext) + ? initializeServerApp(firebaseConfig, { authIdToken: requestContext.authIdToken }) + : initializeServerApp(firebaseConfig, {}); - // Clean up the Firebase server app as required by the SDK. - inject(DestroyRef).onDestroy(() => { - deleteApp(app).catch(error => console.error(error)); - }); - return app; - }, - }, + // Clean up the Firebase server app as required by the SDK. + inject(DestroyRef).onDestroy(() => { + deleteApp(app).catch(error => console.error(error)); + }); + return app; + }), // Wait for auth state to settle before rendering. - provideAppInitializer(() => inject(FIREBASE_AUTH).authStateReady()), + provideAppInitializer(() => inject(Auth).authStateReady()), ], }; diff --git a/recipe-demo/src/app/app.config.ts b/recipe-demo/src/app/app.config.ts index 4e6b29912..a9bc3f45c 100644 --- a/recipe-demo/src/app/app.config.ts +++ b/recipe-demo/src/app/app.config.ts @@ -1,18 +1,18 @@ import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { FirebaseApp } from '@angular/fire/app'; +import { getAuth, provideAuth } from '@angular/fire/auth'; +import { getFirestore, provideFirestore } from '@angular/fire/firestore'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; -import { getAuth } from 'firebase/auth'; -import { getFirestore } from 'firebase/firestore'; import { routes } from './app.routes'; -import { FIREBASE_APP, FIREBASE_AUTH, FIRESTORE } from './firebase-tokens'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideClientHydration(withEventReplay()), provideRouter(routes), - { provide: FIRESTORE, useFactory: () => getFirestore(inject(FIREBASE_APP)) }, - { provide: FIREBASE_AUTH, useFactory: () => getAuth(inject(FIREBASE_APP)) }, + provideFirestore(() => getFirestore(inject(FirebaseApp))), + provideAuth(() => getAuth(inject(FirebaseApp))), ], }; diff --git a/recipe-demo/src/app/auth-store.ts b/recipe-demo/src/app/auth-store.ts index 2b3cf7e17..91f241783 100644 --- a/recipe-demo/src/app/auth-store.ts +++ b/recipe-demo/src/app/auth-store.ts @@ -1,4 +1,5 @@ import { DestroyRef, Injectable, inject, signal } from '@angular/core'; +import { Auth } from '@angular/fire/auth'; import { User, createUserWithEmailAndPassword, @@ -7,11 +8,9 @@ import { signOut, } from 'firebase/auth'; -import { FIREBASE_AUTH } from './firebase-tokens'; - @Injectable({ providedIn: 'root' }) export class AuthStore { - private readonly auth = inject(FIREBASE_AUTH); + private readonly auth = inject(Auth); private readonly user = signal(null); readonly currentUser = this.user.asReadonly(); diff --git a/recipe-demo/src/app/firebase-tokens.ts b/recipe-demo/src/app/firebase-tokens.ts deleted file mode 100644 index 6b71ab0b3..000000000 --- a/recipe-demo/src/app/firebase-tokens.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import type { FirebaseApp } from 'firebase/app'; -import type { Auth } from 'firebase/auth'; -import type { Firestore } from 'firebase/firestore'; -import type { AI } from 'firebase/ai'; - -export const FIREBASE_APP = new InjectionToken('firebase-app'); -export const FIRESTORE = new InjectionToken('firestore'); -export const FIREBASE_AUTH = new InjectionToken('firebase-auth'); -export const FIREBASE_AI = new InjectionToken('firebase-ai'); diff --git a/recipe-demo/src/app/recipe-store.ts b/recipe-demo/src/app/recipe-store.ts index d418e1aad..13cef8aa1 100644 --- a/recipe-demo/src/app/recipe-store.ts +++ b/recipe-demo/src/app/recipe-store.ts @@ -11,6 +11,8 @@ import { resource, signal, } from '@angular/core'; +import { AI } from '@angular/fire/ai'; +import { Firestore } from '@angular/fire/firestore'; import { Schema, getGenerativeModel } from 'firebase/ai'; import { Query, @@ -30,7 +32,6 @@ import { } from 'firebase/firestore'; import { AuthStore } from './auth-store'; -import { FIREBASE_AI, FIRESTORE } from './firebase-tokens'; import { CUISINES, Recipe, @@ -67,9 +68,9 @@ export class RecipeStore { }); // Only the browser config provides this, so generation is unavailable during server rendering. - private readonly ai = inject(FIREBASE_AI, { optional: true }); + private readonly ai = inject(AI, { optional: true }); - private readonly firestore = inject(FIRESTORE); + private readonly firestore = inject(Firestore); private readonly authStore = inject(AuthStore); private readonly transferState = inject(TransferState); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); From 4e8569688fa0616a4d73fb69fdb7477e9d1455e5 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 25 Aug 2026 15:39:42 -0700 Subject: [PATCH 3/8] refactor(recipe-demo): move the signed-in server render onto angularfire The browser's cookie sync moves from a raw onIdTokenChanged subscription with a manual teardown to AngularFire's idToken observable with takeUntilDestroyed. Only the beforeAuthStateChanged half still needs an unsubscribe of its own. The server factory no longer builds a Firebase server app for anonymous requests. A server app carries two things into a render, a user identity and a pre-minted App Check token, and an anonymous request has neither here, so a plain app does the same job with nothing to release. App Check is enforced only on AI Logic, which this app calls from the browser alone. The signed-in path hands the request context to releaseOnDeref, so the SDK drops the app once that object is collected, and the explicit deleteApp teardown goes with it. --- recipe-demo/src/app/app.config.client.ts | 16 +++++----- recipe-demo/src/app/app.config.server.ts | 37 ++++++++++++++---------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/recipe-demo/src/app/app.config.client.ts b/recipe-demo/src/app/app.config.client.ts index c9d893865..8d7386422 100644 --- a/recipe-demo/src/app/app.config.client.ts +++ b/recipe-demo/src/app/app.config.client.ts @@ -5,10 +5,11 @@ import { mergeApplicationConfig, provideAppInitializer, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AgentPlatformBackend, getAI, provideAI } from '@angular/fire/ai'; import { FirebaseApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; import { initializeAppCheck, provideAppCheck, ReCaptchaV3Provider } from '@angular/fire/app-check'; -import { Auth, onIdTokenChanged, Unsubscribe } from '@angular/fire/auth'; +import { Auth, idToken, Unsubscribe } from '@angular/fire/auth'; import { beforeAuthStateChanged } from 'firebase/auth'; import cookies from 'js-cookie'; @@ -21,15 +22,12 @@ function syncSessionCookie(): void { const destroyRef = inject(DestroyRef); // Refresh session cookie on startup/sign-in/sign-out/background token refresh. - const stopSyncOnTokenChange = onIdTokenChanged(auth, async user => { - updateSessionCookie(await user?.getIdToken()); - }); - const stopSyncBeforeAuthChange = syncBeforeAuthChange(auth); + idToken(auth) + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe(token => updateSessionCookie(token ?? undefined)); - destroyRef.onDestroy(() => { - stopSyncOnTokenChange(); - stopSyncBeforeAuthChange(); - }); + const stopSyncBeforeAuthChange = syncBeforeAuthChange(auth); + destroyRef.onDestroy(stopSyncBeforeAuthChange); } /** Set or remove the session cookie. */ diff --git a/recipe-demo/src/app/app.config.server.ts b/recipe-demo/src/app/app.config.server.ts index 7bc726cb7..f8adbe4d6 100644 --- a/recipe-demo/src/app/app.config.server.ts +++ b/recipe-demo/src/app/app.config.server.ts @@ -1,12 +1,16 @@ import { ApplicationConfig, - DestroyRef, REQUEST_CONTEXT, inject, mergeApplicationConfig, provideAppInitializer, } from '@angular/core'; -import { deleteApp, initializeServerApp, provideFirebaseApp } from '@angular/fire/app'; +import { + FirebaseApp, + initializeApp, + initializeServerApp, + provideFirebaseApp, +} from '@angular/fire/app'; import { Auth } from '@angular/fire/auth'; import { provideServerRendering, withRoutes } from '@angular/ssr'; @@ -14,6 +18,21 @@ import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; import { firebaseConfig } from './firebase-config'; +function createFirebaseApp(): FirebaseApp { + // Pass REQUEST_CONTEXT's authIdToken to render personalized signed-in content. + const requestContext = inject(REQUEST_CONTEXT, { optional: true }); + + // Anonymous requests need a server app only to carry an App Check token (not needed in our app). + if (!hasAuthIdToken(requestContext)) { + return initializeApp(firebaseConfig); + } + // Cleanup server app on requestContext garbage collection, which goes with the render. + return initializeServerApp(firebaseConfig, { + authIdToken: requestContext.authIdToken, + releaseOnDeref: requestContext, + }); +} + // REQUEST_CONTEXT is `unknown`. It contains an authIdToken property when user is signed-in. function hasAuthIdToken(context: unknown): context is { authIdToken: string } { if (!context || typeof context !== 'object' || !('authIdToken' in context)) { @@ -25,19 +44,7 @@ function hasAuthIdToken(context: unknown): context is { authIdToken: string } { const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), - provideFirebaseApp(() => { - // Pass REQUEST_CONTEXT's authIdToken to render personalized signed-in content. - const requestContext = inject(REQUEST_CONTEXT, { optional: true }); - const app = hasAuthIdToken(requestContext) - ? initializeServerApp(firebaseConfig, { authIdToken: requestContext.authIdToken }) - : initializeServerApp(firebaseConfig, {}); - - // Clean up the Firebase server app as required by the SDK. - inject(DestroyRef).onDestroy(() => { - deleteApp(app).catch(error => console.error(error)); - }); - return app; - }), + provideFirebaseApp(createFirebaseApp), // Wait for auth state to settle before rendering. provideAppInitializer(() => inject(Auth).authStateReady()), ], From 610cb290614aa67410ba188e7c2150a3f138a576 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 25 Aug 2026 20:33:49 -0700 Subject: [PATCH 4/8] refactor(recipe-demo): let angularfire supply the server's auth wait AuthStore drops its hand-rolled onAuthStateChanged subscription, its signal and its teardown for one toSignal over authState. The guard stops going through AuthStore and reads the same observable directly, because a signal cannot distinguish "not resolved yet" from "resolved, signed out". Both read null, and a guard that confuses them sends a signed-in visitor to the sign-in page. The server config loses its provideAppInitializer that awaited authStateReady. AngularFire holds a pending task open from the moment authState is subscribed until its first emission, and AuthStore subscribes as soon as a component injects it, so the render already waits without the initializer. The two are not the same guarantee. The initializer blocked bootstrap, so nothing ran until auth resolved. A pending task only defers the point at which the render counts as stable, so work can still run before auth resolves. Data reads are unaffected, because Firestore waits for its own credentials. --- recipe-demo/src/app/app.config.server.ts | 13 ++------- recipe-demo/src/app/auth-guard.ts | 20 +++++++------- recipe-demo/src/app/auth-store.ts | 34 ++++++++++-------------- 3 files changed, 26 insertions(+), 41 deletions(-) diff --git a/recipe-demo/src/app/app.config.server.ts b/recipe-demo/src/app/app.config.server.ts index f8adbe4d6..d98a12fb1 100644 --- a/recipe-demo/src/app/app.config.server.ts +++ b/recipe-demo/src/app/app.config.server.ts @@ -1,17 +1,10 @@ -import { - ApplicationConfig, - REQUEST_CONTEXT, - inject, - mergeApplicationConfig, - provideAppInitializer, -} from '@angular/core'; +import { ApplicationConfig, REQUEST_CONTEXT, inject, mergeApplicationConfig } from '@angular/core'; import { FirebaseApp, initializeApp, initializeServerApp, provideFirebaseApp, } from '@angular/fire/app'; -import { Auth } from '@angular/fire/auth'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; @@ -22,7 +15,7 @@ function createFirebaseApp(): FirebaseApp { // Pass REQUEST_CONTEXT's authIdToken to render personalized signed-in content. const requestContext = inject(REQUEST_CONTEXT, { optional: true }); - // Anonymous requests need a server app only to carry an App Check token (not needed in our app). + // Anonymous requests need a server app only to carry an App Check token (not needed in this app). if (!hasAuthIdToken(requestContext)) { return initializeApp(firebaseConfig); } @@ -45,8 +38,6 @@ const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), provideFirebaseApp(createFirebaseApp), - // Wait for auth state to settle before rendering. - provideAppInitializer(() => inject(Auth).authStateReady()), ], }; diff --git a/recipe-demo/src/app/auth-guard.ts b/recipe-demo/src/app/auth-guard.ts index 6289938eb..d4e3c1398 100644 --- a/recipe-demo/src/app/auth-guard.ts +++ b/recipe-demo/src/app/auth-guard.ts @@ -1,16 +1,16 @@ import { inject } from '@angular/core'; +import { Auth, authState } from '@angular/fire/auth'; import { CanActivateFn, Router } from '@angular/router'; +import { map } from 'rxjs'; -import { AuthStore } from './auth-store'; - -export const authGuard: CanActivateFn = async (route, state) => { - // Both injections must happen before the first await, which ends the injection context. - const authStore = inject(AuthStore); +export const authGuard: CanActivateFn = (route, state) => { + const auth = inject(Auth); const router = inject(Router); - await authStore.whenAuthResolved(); - if (authStore.currentUser()) { - return true; - } - return router.createUrlTree(['/signin'], { queryParams: { returnUrl: state.url } }); + // authState does not emit until Firebase has restored the session. + return authState(auth).pipe( + map(user => + user ? true : router.createUrlTree(['/signin'], { queryParams: { returnUrl: state.url } }), + ), + ); }; diff --git a/recipe-demo/src/app/auth-store.ts b/recipe-demo/src/app/auth-store.ts index 91f241783..427fcd451 100644 --- a/recipe-demo/src/app/auth-store.ts +++ b/recipe-demo/src/app/auth-store.ts @@ -1,39 +1,33 @@ -import { DestroyRef, Injectable, inject, signal } from '@angular/core'; -import { Auth } from '@angular/fire/auth'; +import { EnvironmentInjector, Injectable, inject, runInInjectionContext } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { - User, + Auth, + authState, createUserWithEmailAndPassword, - onAuthStateChanged, signInWithEmailAndPassword, signOut, -} from 'firebase/auth'; +} from '@angular/fire/auth'; @Injectable({ providedIn: 'root' }) export class AuthStore { private readonly auth = inject(Auth); - private readonly user = signal(null); + private readonly injector = inject(EnvironmentInjector); - readonly currentUser = this.user.asReadonly(); - - constructor() { - const unsubscribe = onAuthStateChanged(this.auth, user => this.user.set(user)); - inject(DestroyRef).onDestroy(unsubscribe); - } - - /** Resolves once Firebase has restored any persisted session. */ - whenAuthResolved(): Promise { - return this.auth.authStateReady(); - } + readonly currentUser = toSignal(authState(this.auth), { initialValue: null }); async signIn(email: string, password: string): Promise { - await signInWithEmailAndPassword(this.auth, email, password); + await runInInjectionContext(this.injector, () => + signInWithEmailAndPassword(this.auth, email, password), + ); } async createAccount(email: string, password: string): Promise { - await createUserWithEmailAndPassword(this.auth, email, password); + await runInInjectionContext(this.injector, () => + createUserWithEmailAndPassword(this.auth, email, password), + ); } signOut(): Promise { - return signOut(this.auth); + return runInInjectionContext(this.injector, () => signOut(this.auth)); } } From 04d7f3021c7d47ae16bdb59f9c0da9c47838076e Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 25 Aug 2026 22:38:42 -0700 Subject: [PATCH 5/8] refactor(recipe-demo): rebuild the recipe read on angularfire The browser reads recipes through a live listener via collectionData. The server performs a single read via getDocs. rxResource is used to more easily work with AngularFire's RxJs observables. AngularFire-wrapped functions that need an injection context are wrapped in runInInjectionContext. The document-reading converter goes. Dropping it is what makes the read return untyped documents, and the two casts that follow are the price. They are deliberate and they are the measured cost of the current API, which is why they are written rather than worked around. TransferState stays hand-wired: the state key, the set on the server, the signal seeded from it, the computed that prefers the transferred list, and the clearing once the listener answers. AngularFire supplies none of those. --- recipe-demo/src/app/recipe-converter.ts | 9 +- recipe-demo/src/app/recipe-store.ts | 104 +++++++++++------------- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/recipe-demo/src/app/recipe-converter.ts b/recipe-demo/src/app/recipe-converter.ts index 510871e7a..434224c23 100644 --- a/recipe-demo/src/app/recipe-converter.ts +++ b/recipe-demo/src/app/recipe-converter.ts @@ -38,18 +38,11 @@ export function toRecipe(id: string, data: Record): Recipe { }; } -export const recipeConverter: FirestoreDataConverter = { - toFirestore(): DocumentData { - throw new Error('Recipes are written through recipeDraftConverter.'); - }, - fromFirestore: snapshot => toRecipe(snapshot.id, snapshot.data()), -}; - export const recipeDraftConverter: FirestoreDataConverter = { toFirestore(draft: WithFieldValue): DocumentData { return { ...draft }; }, fromFirestore(): RecipeDraft { - throw new Error('Recipes are read through recipeConverter.'); + throw new Error('Recipes are read as untyped documents.'); }, }; diff --git a/recipe-demo/src/app/recipe-store.ts b/recipe-demo/src/app/recipe-store.ts index 13cef8aa1..df8e9c304 100644 --- a/recipe-demo/src/app/recipe-store.ts +++ b/recipe-demo/src/app/recipe-store.ts @@ -1,5 +1,6 @@ import { isPlatformBrowser } from '@angular/common'; import { + EnvironmentInjector, Injectable, PLATFORM_ID, ResourceStreamItem, @@ -9,37 +10,37 @@ import { inject, makeStateKey, resource, + runInInjectionContext, signal, } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { AI } from '@angular/fire/ai'; -import { Firestore } from '@angular/fire/firestore'; -import { Schema, getGenerativeModel } from 'firebase/ai'; import { + Firestore, Query, - addDoc, collection, - deleteDoc, - doc, + collectionData, getDocs, - increment, limit, - onSnapshot, orderBy, query, - serverTimestamp, where, +} from '@angular/fire/firestore'; +import { Schema, getGenerativeModel } from 'firebase/ai'; +import { + addDoc, + deleteDoc, + doc, + increment, + onSnapshot, + serverTimestamp, writeBatch, } from 'firebase/firestore'; +import { Observable, from } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { AuthStore } from './auth-store'; -import { - CUISINES, - Recipe, - RecipeDraft, - recipeConverter, - recipeDraftConverter, - toRecipe, -} from './recipe-converter'; +import { CUISINES, Recipe, RecipeDraft, recipeDraftConverter, toRecipe } from './recipe-converter'; export type RecipeSort = 'newest' | 'title'; @@ -71,6 +72,7 @@ export class RecipeStore { private readonly ai = inject(AI, { optional: true }); private readonly firestore = inject(Firestore); + private readonly injector = inject(EnvironmentInjector); private readonly authStore = inject(AuthStore); private readonly transferState = inject(TransferState); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); @@ -86,9 +88,9 @@ export class RecipeStore { // Idle until a page asks for the list. The store is root provided and /create-recipe injects it // only to generate, so without this its server render would wait on a read it never displays. - private readonly recipeResource = resource({ + private readonly recipeResource = rxResource({ params: () => (this.listWanted() ? { cuisine: this.cuisine(), sort: this.sort() } : undefined), - stream: ({ params, abortSignal }) => this.readRecipes(params.cuisine, params.sort, abortSignal), + stream: ({ params }) => this.readRecipes(params.cuisine, params.sort), defaultValue: [], }); @@ -256,44 +258,32 @@ export class RecipeStore { } } - /** Loads the recipes for one cuisine and sort order and hands back the signal - * the resource reads from. In the browser that signal keeps changing as - * Firestore pushes new snapshots. */ - private async readRecipes( - cuisine: string, - sort: RecipeSort, - abortSignal: AbortSignal, - ): Promise>> { - const recipeQuery = this.buildQuery(cuisine, sort); - if (!this.isBrowser) { - // The server reads once instead of subscribing. A listener would stay - // open and the render would never finish. - const snapshot = await getDocs(recipeQuery); - const serverRecipes = snapshot.docs.map(recipeDoc => recipeDoc.data()); - this.transferState.set(RecipeStore.SERVER_RENDERED_RECIPES, { - cuisine, - sort, - recipes: serverRecipes, - }); - return signal({ value: serverRecipes }); - } - // A live listener in the browser, torn down through the abort signal the - // resource raises when the query changes or the store is destroyed. - const recipes = signal>({ value: [] }); - // Once this listener has responded, the server's list is stale. Without discarding it, returning - // to the filter the server rendered would show that old list again. - const unsubscribe = onSnapshot( - recipeQuery, - snapshot => { - this.serverRendered.set(null); - recipes.set({ value: snapshot.docs.map(recipeDoc => recipeDoc.data()) }); - }, - error => { - this.serverRendered.set(null); - recipes.set({ error }); - }, + /** Streams the recipes for one cuisine and sort order. */ + private readRecipes(cuisine: string, sort: RecipeSort): Observable { + return runInInjectionContext(this.injector, () => + this.isBrowser + ? // Open a live listener in the browser. + collectionData(this.buildQuery(cuisine, sort), { idField: 'id' }).pipe( + // Discard the now-stale list sent from the server once the listener has responded. + tap({ + next: () => this.serverRendered.set(null), + error: () => this.serverRendered.set(null), + }), + map(documents => documents as Recipe[]), + ) + : // Perform a single read on the server. + from(this.readOnceAndTransfer(cuisine, sort)), ); - abortSignal.addEventListener('abort', unsubscribe); + } + + /** Reads the list once for the server render and hands the data to TransferState, so the + * first browser paint shows what the server already rendered. */ + private async readOnceAndTransfer(cuisine: string, sort: RecipeSort): Promise { + const snapshot = await getDocs(this.buildQuery(cuisine, sort)); + const recipes = snapshot.docs.map( + recipeDoc => ({ ...recipeDoc.data(), id: recipeDoc.id }) as Recipe, + ); + this.transferState.set(RecipeStore.SERVER_RENDERED_RECIPES, { cuisine, sort, recipes }); return recipes; } @@ -322,9 +312,9 @@ export class RecipeStore { } // A production list would add cursor pagination on top of this limit. - private buildQuery(cuisine: string, sort: RecipeSort): Query { + private buildQuery(cuisine: string, sort: RecipeSort): Query { const order = sort === 'newest' ? orderBy('createdAt', 'desc') : orderBy('title'); - const recipesRef = collection(this.firestore, 'recipes').withConverter(recipeConverter); + const recipesRef = collection(this.firestore, 'recipes'); return cuisine === 'all' ? query(recipesRef, order, limit(20)) : query(recipesRef, where('cuisine', '==', cuisine), order, limit(20)); From 19edebc4984bc003c2311b70213f0de7afe58ad7 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 26 Aug 2026 02:11:42 -0700 Subject: [PATCH 6/8] refactor(recipe-demo): move likes and writes onto angularfire The likes read becomes an rxResource over collectionData, keeping an explicit readiness flag. The flag resets in finalize alone, which works because rxResource tears the old stream down before subscribing the new one. The writes now import from AngularFire, which has a cost. An AngularFire-wrapped function needs an injection context. --- recipe-demo/src/app/recipe-store.ts | 111 +++++++++++++--------------- 1 file changed, 52 insertions(+), 59 deletions(-) diff --git a/recipe-demo/src/app/recipe-store.ts b/recipe-demo/src/app/recipe-store.ts index df8e9c304..f3ed5f9b5 100644 --- a/recipe-demo/src/app/recipe-store.ts +++ b/recipe-demo/src/app/recipe-store.ts @@ -3,41 +3,34 @@ import { EnvironmentInjector, Injectable, PLATFORM_ID, - ResourceStreamItem, - Signal, TransferState, computed, inject, makeStateKey, - resource, runInInjectionContext, signal, } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; -import { AI } from '@angular/fire/ai'; +import { AI, Schema, getGenerativeModel } from '@angular/fire/ai'; import { Firestore, Query, + addDoc, collection, collectionData, + deleteDoc, + doc, getDocs, + increment, limit, orderBy, query, - where, -} from '@angular/fire/firestore'; -import { Schema, getGenerativeModel } from 'firebase/ai'; -import { - addDoc, - deleteDoc, - doc, - increment, - onSnapshot, serverTimestamp, + where, writeBatch, -} from 'firebase/firestore'; -import { Observable, from } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +} from '@angular/fire/firestore'; +import { Observable, from, of } from 'rxjs'; +import { finalize, map, tap } from 'rxjs/operators'; import { AuthStore } from './auth-store'; import { CUISINES, Recipe, RecipeDraft, recipeDraftConverter, toRecipe } from './recipe-converter'; @@ -116,10 +109,10 @@ export class RecipeStore { readonly error = this.recipeResource.error; // Idle the resource with undefined so no snapshot subscription occurs on the server. - // null instead at sign-out, so Angular aborts the previous load and unsubscribes. - private readonly likedResource = resource({ + // null instead at sign-out, so Angular unsubscribes the previous listener. + private readonly likedResource = rxResource({ params: () => (this.isBrowser ? (this.authStore.currentUser()?.uid ?? null) : undefined), - stream: ({ params: uid, abortSignal }) => this.readLikedIds(uid, abortSignal), + stream: ({ params: uid }) => this.readLikedIds(uid), defaultValue: RecipeStore.NO_LIKES, }); @@ -168,7 +161,9 @@ export class RecipeStore { createdBy: user.uid, likeCount: 0, }; - await addDoc(collection(this.firestore, 'recipes').withConverter(recipeDraftConverter), draft); + await runInInjectionContext(this.injector, () => + addDoc(collection(this.firestore, 'recipes').withConverter(recipeDraftConverter), draft), + ); } /** One model call, parsed and checked. Throws if the model returned nothing usable. */ @@ -176,13 +171,16 @@ export class RecipeStore { if (!this.ai) { throw new Error('Recipe generation is not configured in this build.'); } - const model = getGenerativeModel(this.ai, { - model: 'gemini-3.7-flash', - generationConfig: { - responseMimeType: 'application/json', - responseSchema: RecipeStore.RECIPE_SCHEMA, - }, - }); + const ai = this.ai; + const model = runInInjectionContext(this.injector, () => + getGenerativeModel(ai, { + model: 'gemini-3.7-flash', + generationConfig: { + responseMimeType: 'application/json', + responseSchema: RecipeStore.RECIPE_SCHEMA, + }, + }), + ); const { response } = await model.generateContent( 'Invent one original dinner recipe. Keep ingredients and instructions concise.', ); @@ -220,19 +218,22 @@ export class RecipeStore { this.likeErrorRecipe.set(null); this.deleteErrorRecipe.set(null); try { - const likeRef = doc(this.firestore, `users/${user.uid}/likes/${recipe.id}`); - const recipeRef = doc(this.firestore, `recipes/${recipe.id}`); - const batch = writeBatch(this.firestore); - if (this.likedIds().has(recipe.id)) { - batch.delete(likeRef); - if (recipe.likeCount > 0) { - batch.update(recipeRef, { likeCount: increment(-1) }); + const likeBatch = runInInjectionContext(this.injector, () => { + const likeRef = doc(this.firestore, `users/${user.uid}/likes/${recipe.id}`); + const recipeRef = doc(this.firestore, `recipes/${recipe.id}`); + const batch = writeBatch(this.firestore); + if (this.likedIds().has(recipe.id)) { + batch.delete(likeRef); + if (recipe.likeCount > 0) { + batch.update(recipeRef, { likeCount: increment(-1) }); + } + } else { + batch.set(likeRef, {}); + batch.update(recipeRef, { likeCount: increment(1) }); } - } else { - batch.set(likeRef, {}); - batch.update(recipeRef, { likeCount: increment(1) }); - } - await batch.commit(); + return batch; + }); + await likeBatch.commit(); } catch (error) { console.error(error); this.likeErrorRecipe.set(recipe.title); @@ -251,7 +252,9 @@ export class RecipeStore { this.deleteErrorRecipe.set(null); this.likeErrorRecipe.set(null); try { - await deleteDoc(doc(this.firestore, `recipes/${recipe.id}`)); + await runInInjectionContext(this.injector, () => + deleteDoc(doc(this.firestore, `recipes/${recipe.id}`)), + ); } catch (error) { console.error(error); this.deleteErrorRecipe.set(recipe.title); @@ -287,28 +290,18 @@ export class RecipeStore { return recipes; } - /** Streams the ids this user has liked. The abort signal unsubscribes the listener. */ - private async readLikedIds( - uid: string | null, - abortSignal: AbortSignal, - ): Promise>>> { - this.likesReady.set(false); - const likedIds = signal>>({ - value: RecipeStore.NO_LIKES, - }); + /** Streams the ids this user has liked. */ + private readLikedIds(uid: string | null): Observable> { if (uid === null) { - return likedIds; + return of(RecipeStore.NO_LIKES); } - const unsubscribe = onSnapshot( - collection(this.firestore, `users/${uid}/likes`), - snapshot => { - likedIds.set({ value: new Set(snapshot.docs.map(likeDoc => likeDoc.id)) }); - this.likesReady.set(true); - }, - error => likedIds.set({ error }), + return runInInjectionContext(this.injector, () => + collectionData(collection(this.firestore, `users/${uid}/likes`), { idField: 'id' }).pipe( + tap(() => this.likesReady.set(true)), + map((likes): ReadonlySet => new Set(likes.map(like => String(like.id)))), + finalize(() => this.likesReady.set(false)), + ), ); - abortSignal.addEventListener('abort', unsubscribe); - return likedIds; } // A production list would add cursor pagination on top of this limit. From d1150ed55dc37763ba6835091923dc98a78e384b Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 26 Aug 2026 05:15:58 -0700 Subject: [PATCH 7/8] docs(recipe-demo): rewrite the readme for the angularfire branch Anything the two branches share is inherited from branch 1 unchanged. A README difference that AngularFire did not cause is noise in the diff that is the whole deliverable. The production gaps are deliberate and each says what it costs. The unvalidated read is the current state of the library rather than a workaround, and the two casts it needs are named. --- recipe-demo/README.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/recipe-demo/README.md b/recipe-demo/README.md index 618b28d00..9d6516689 100644 --- a/recipe-demo/README.md +++ b/recipe-demo/README.md @@ -1,6 +1,8 @@ -# Recipe demo, raw Firebase JS SDK +# Recipe demo, AngularFire -A minimal recipe app built on the Firebase JS SDK directly. It uses four Firebase features: Authentication, Firestore, AI Logic, and App Check. +A minimal recipe app built with AngularFire. It uses four Firebase features: Authentication, Firestore, AI Logic, and App Check. + +It is the same application as the `recipe-demo` branch, whose Firebase layer calls the Firebase JS SDK directly. Only the Firebase layer differs, so the diff between the two branches is what AngularFire changes and nothing else. This is not a starter template and not production-ready. Known gaps are listed under [Production gaps](#production-gaps) rather than solved. @@ -12,7 +14,7 @@ This is not a starter template and not production-ready. Known gaps are listed u ## Deployed instance - +To be added when this branch's App Hosting backend is deployed. ## Design decisions @@ -46,14 +48,20 @@ This demo app is meant to be a minimal app, so unit tests were omitted. This was Simpler to set up, and it avoids emulator-specific behavior. The cost is that you need a real project and a network connection to run anything at all. +### The per-request server app is released by the garbage collector + +Rendering a page for a signed-in visitor creates a `FirebaseServerApp` carrying that visitor's identity, and the Firebase SDK requires the application to dispose of it. There are two ways to do that, and this branch takes the one AngularFire's own auth guide teaches: the server factory hands the request context to `initializeServerApp` as `releaseOnDeref`, and the SDK watches that object with a `FinalizationRegistry`, releasing the server app once the object is collected. + ## Production gaps -Deliberately left open, and labelled rather than solved. +Deliberately left open, and labeled rather than solved. - **No pagination.** The list is a flat `limit(20)`, so a 21st recipe is unreachable. A real app would page with `startAfter()`. - **Like documents are left behind on delete.** Deleting a recipe removes the recipe, but every user who liked it keeps a `users/{uid}/likes/{recipeId}` document pointing at a recipe that no longer exists. Clearing those needs a Cloud Functions trigger, which is out of scope for this demo. - **No rate limiting on generation.** The only brake on the Generate recipe button is that it disables itself while a request is in flight. A real app would limit per user, server-side. - **No server-side App Check token forwarding.** If the browser forwarded an App Check token with the page request and the server passed it to `initializeServerApp` as `appCheckToken`, and the seed script authenticated with a registered debug token, App Check could be enforced on Firestore and Auth as well. Until then, anyone who copies the public web config out of this repo can read and write Firestore from a script of their own, held back only by the security rules and not by any check that the request came from this app. +- **The recipe read is unvalidated.** `collectionData(query, { idField: 'id' })` returns untyped documents, and nothing checks that a document matches the `Recipe` interface before the templates read it. `recipe-store.ts` handles that with two casts, `map(documents => documents as Recipe[])` on the browser listener and `as Recipe` on the server read. +- **The server knows who the visitor is, but never reads anything as them.** `initializeServerApp` is handed the visitor's ID token, so `auth.currentUser` is populated while the page renders on the server. Nothing then uses it. The recipe list is a public read that returns the same documents whoever asks, and the likes data only loads in the browser. So no Firestore security rule is ever evaluated against that token, and this app has not shown that the token would satisfy one. ## Setup @@ -70,7 +78,7 @@ The committed `src/app/firebase-config.ts` points at the project this demo was b ### 2. The AI Logic provider, which is not optional -The app calls `getAI(app, { backend: new AgentPlatformBackend() })`, so enabling only the Gemini Developer API provider is not enough. Enable the Agent Platform provider or generation fails. Two failure signatures are worth recognizing: +`app.config.client.ts` provides AI as `provideAI(() => getAI(inject(FirebaseApp), { backend: new AgentPlatformBackend() }))`, so enabling only the Gemini Developer API provider is not enough. Enable the Agent Platform provider or generation fails. Two failure signatures are worth recognizing: - **429 `RESOURCE_EXHAUSTED`, saying prepayment credits are depleted.** The request went to the Gemini Developer API backend, whose prepay balance Google Cloud credit cannot fund. Enable the Agent Platform provider. - **404 `NOT_FOUND` naming `locations/us-central1`.** The request went through the deprecated `VertexAIBackend`, whose no-argument default is `us-central1`. `AgentPlatformBackend` defaults to `global`, which is what Firebase recommends, and what this app uses. @@ -79,7 +87,7 @@ The app calls `getAI(app, { backend: new AgentPlatformBackend() })`, so enabling Enforcement is **on for AI Logic only**. Firestore and Auth are left unenforced on purpose: -- The server renders with `initializeServerApp` and no App Check token, and the reCAPTCHA provider cannot run in Node, so enforcing App Check on Firestore would break every server render. +- The server render never carries an App Check token, and the reCAPTCHA provider cannot run in Node, so enforcing App Check on Firestore would break every server render. - `seed.mjs` signs in and writes from Node with no provider either, so enforcing on Auth or Firestore would break the seed script. AI Logic is called only from the browser, where App Check does run. That is where it matters most. The web config in this repo is public, so anyone can copy it and call this project's Gemini endpoint from a script of their own, and the bill lands on the project. Firestore has security rules to limit what a stranger can do with the same config. AI Logic has no equivalent, so App Check is the only thing requiring those calls to come from this app. Closing the rest of the hole needs server-side token forwarding, which is listed under [Production gaps](#production-gaps). @@ -102,6 +110,12 @@ SEED_EMAIL=you@example.com SEED_PASSWORD=... node seed.mjs Re-running deletes that account's own recipes before writing, so it doubles as a reset. +### 6. AngularFire is installed by hand, not with `ng add` + +`@angular/fire` is pinned to an exact canary in `package.json` and its providers are written by hand. `ng add @angular/fire` was not used, because it throws on this app's configuration layout. + +The cause is narrower than "the split client and server config". The schematic's `findAppConfig` cannot resolve a config that is the result of a `mergeApplicationConfig` call assigned to a variable, which is what both `app.config.client.ts` and `app.config.server.ts` do. + ## Running it ```bash @@ -117,7 +131,9 @@ npm run serve:ssr:recipe-demo ### Reviewer note, please read before reporting a failure -**Browse via `localhost`, not `127.0.0.1`.** Two separate things break on the numeric address. The built server rejects it outright with `HTTP 400`, because `angular.json` does not list it among the allowed hosts. And the app sets its App Check debug flag only when `location.hostname` is exactly `localhost`, so on any other host name you would hit real reCAPTCHA rather than a debug token. +**Browse via `localhost`, not `127.0.0.1`.** The built server rejects the numeric address with `HTTP 400`, because `angular.json` does not list it among the allowed hosts. That happens before any JavaScript in the app runs. + +The raw SDK branch had a second reason for the same advice, and this branch does not. That branch set the App Check debug flag itself, guarded on `location.hostname` being exactly `localhost`, so any other host name reached real reCAPTCHA instead of a debug token. Nothing here sets that flag. AngularFire's `appCheckInstanceFactory` sets it, on a condition wider than the hand-written one at both ends: it fires whenever the platform is not the server and either `isDevMode()` is true or the hostname is one of `localhost`, `0.0.0.0` and `127.0.0.1`. **A fresh machine or a fresh browser profile mints a new App Check debug token.** On the first load from `localhost` the console logs one line: @@ -128,3 +144,15 @@ Firebase App Check debug token: 00000000-0000-0000-0000-000000000000 That token has to be registered in the Firebase console, under App Check, on the web app's Manage debug tokens. Until it is, AI Logic returns `401` and the console repeats `exchangeDebugToken` `403`. Everything except recipe generation keeps working, because enforcement is AI Logic only. If you would rather not register a token, use the deployed URL above instead. + +## How Angular 22 would differ + +The short version is that the application code would barely move and the dependency would not resolve at all. + +**How this was checked, because it limits how much the next two points are worth.** Nobody built this app against Angular 22, and nobody can: npm refuses to install the two together, because no published `@angular/fire` allows an Angular 22 peer. So instead of compiling anything, the two versions of Angular's published type declaration files were downloaded and compared by reading, at 21.2.21 and 22.1.3. That is weaker evidence than a build. It catches a changed signature and it would not catch a changed behavior. + +**One thing changes.** + +- **`rxResource` stops being experimental.** The recipe list is an `rxResource` over `collectionData`. In 21.2.21 both of its overloads, and the `RxResourceOptions` interface behind them, are tagged `@experimental`. In 22.1.3 all three are tagged `@publicApi 22.0`. That is nearly the whole difference between the two declaration files: fourteen changed lines, being those three tags, three import paths, and the version number in the file's license header. The call in `recipe-store.ts` would compile unchanged. + +**Where to look first, once AngularFire supports Angular 22.** The parts worth re-checking are the ones where the library reads Angular's own state rather than re-exporting a Firebase call: `ɵzoneWrap`, which decides both its log level and its wrapping path from Angular internals, and the `PendingTasks` registration inside it that holds the server render open until a read settles. From c745260deb9100ead2d632c5536f7d5e075323c9 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 26 Aug 2026 06:12:09 -0700 Subject: [PATCH 8/8] build(recipe-demo): point the deploy at this branch's own backend The inherited config named raw-sdk, which is the other branch's live site. firebase init apphosting made that worse rather than better, turning the apphosting field into an array holding both backends, so a deploy could have reached the wrong one. allowedHosts carried the same four raw-sdk hostnames. Without replacing them the deployed site answers HTTP 400 to every request. apphosting.yaml is deliberately not in this commit. The init rewrote it with a newer template, twenty lines of added comments and no change to any setting, and reverting it keeps the branch-to-branch diff free of noise the CLI introduced rather than AngularFire. --- recipe-demo/README.md | 2 +- recipe-demo/angular.json | 8 ++++---- recipe-demo/firebase.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/recipe-demo/README.md b/recipe-demo/README.md index 9d6516689..9dd9a88d1 100644 --- a/recipe-demo/README.md +++ b/recipe-demo/README.md @@ -14,7 +14,7 @@ This is not a starter template and not production-ready. Known gaps are listed u ## Deployed instance -To be added when this branch's App Hosting backend is deployed. +https://angularfire--recipe-demo-97859.us-central1.hosted.app ## Design decisions diff --git a/recipe-demo/angular.json b/recipe-demo/angular.json index 52ca1e4ea..9585edb34 100644 --- a/recipe-demo/angular.json +++ b/recipe-demo/angular.json @@ -60,10 +60,10 @@ "security": { "allowedHosts": [ "localhost", - "raw-sdk--recipe-demo-97859.us-central1.hosted.app", - "raw-sdk--recipe-demo-97859.web.app", - "raw-sdk--recipe-demo-97859.firebaseapp.com", - "raw-sdk-241663837838.us-central1.run.app" + "angularfire--recipe-demo-97859.us-central1.hosted.app", + "angularfire--recipe-demo-97859.web.app", + "angularfire--recipe-demo-97859.firebaseapp.com", + "angularfire-241663837838.us-central1.run.app" ] }, "ssr": { diff --git a/recipe-demo/firebase.json b/recipe-demo/firebase.json index 6d0de34ed..d40abb93d 100644 --- a/recipe-demo/firebase.json +++ b/recipe-demo/firebase.json @@ -4,7 +4,7 @@ "indexes": "firestore.indexes.json" }, "apphosting": { - "backendId": "raw-sdk", + "backendId": "angularfire", "rootDir": "/", "ignore": [ "node_modules",