diff --git a/recipe-demo/README.md b/recipe-demo/README.md index 618b28d00..9dd9a88d1 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 - +https://angularfire--recipe-demo-97859.us-central1.hosted.app ## 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. 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", 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", diff --git a/recipe-demo/src/app/app.config.client.ts b/recipe-demo/src/app/app.config.client.ts index b610bc802..8d7386422 100644 --- a/recipe-demo/src/app/app.config.client.ts +++ b/recipe-demo/src/app/app.config.client.ts @@ -5,44 +5,29 @@ 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 { 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, idToken, 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. - 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. */ @@ -66,11 +51,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..d98a12fb1 100644 --- a/recipe-demo/src/app/app.config.server.ts +++ b/recipe-demo/src/app/app.config.server.ts @@ -1,18 +1,30 @@ +import { ApplicationConfig, REQUEST_CONTEXT, inject, mergeApplicationConfig } from '@angular/core'; import { - ApplicationConfig, - DestroyRef, - REQUEST_CONTEXT, - inject, - mergeApplicationConfig, - provideAppInitializer, -} from '@angular/core'; + FirebaseApp, + initializeApp, + initializeServerApp, + provideFirebaseApp, +} from '@angular/fire/app'; 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'; + +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 this 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 } { @@ -25,24 +37,7 @@ 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, {}); - - // 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()), + provideFirebaseApp(createFirebaseApp), ], }; 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-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 2b3cf7e17..427fcd451 100644 --- a/recipe-demo/src/app/auth-store.ts +++ b/recipe-demo/src/app/auth-store.ts @@ -1,40 +1,33 @@ -import { DestroyRef, Injectable, inject, signal } from '@angular/core'; +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'; - -import { FIREBASE_AUTH } from './firebase-tokens'; +} from '@angular/fire/auth'; @Injectable({ providedIn: 'root' }) export class AuthStore { - private readonly auth = inject(FIREBASE_AUTH); - private readonly user = signal(null); - - readonly currentUser = this.user.asReadonly(); + private readonly auth = inject(Auth); + private readonly injector = inject(EnvironmentInjector); - 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)); } } 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-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 d418e1aad..f3ed5f9b5 100644 --- a/recipe-demo/src/app/recipe-store.ts +++ b/recipe-demo/src/app/recipe-store.ts @@ -1,44 +1,39 @@ import { isPlatformBrowser } from '@angular/common'; import { + EnvironmentInjector, Injectable, PLATFORM_ID, - ResourceStreamItem, - Signal, TransferState, computed, inject, makeStateKey, - resource, + runInInjectionContext, signal, } from '@angular/core'; -import { Schema, getGenerativeModel } from 'firebase/ai'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { AI, Schema, getGenerativeModel } from '@angular/fire/ai'; import { + Firestore, Query, addDoc, collection, + collectionData, deleteDoc, doc, getDocs, increment, limit, - onSnapshot, orderBy, query, serverTimestamp, where, writeBatch, -} from 'firebase/firestore'; +} from '@angular/fire/firestore'; +import { Observable, from, of } from 'rxjs'; +import { finalize, map, tap } from 'rxjs/operators'; import { AuthStore } from './auth-store'; -import { FIREBASE_AI, FIRESTORE } from './firebase-tokens'; -import { - CUISINES, - Recipe, - RecipeDraft, - recipeConverter, - recipeDraftConverter, - toRecipe, -} from './recipe-converter'; +import { CUISINES, Recipe, RecipeDraft, recipeDraftConverter, toRecipe } from './recipe-converter'; export type RecipeSort = 'newest' | 'title'; @@ -67,9 +62,10 @@ 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 injector = inject(EnvironmentInjector); private readonly authStore = inject(AuthStore); private readonly transferState = inject(TransferState); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); @@ -85,9 +81,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: [], }); @@ -113,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, }); @@ -165,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. */ @@ -173,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.', ); @@ -217,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); @@ -248,82 +252,62 @@ 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); } } - /** 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)), + ); + } + + /** 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, ); - abortSignal.addEventListener('abort', unsubscribe); + this.transferState.set(RecipeStore.SERVER_RENDERED_RECIPES, { cuisine, sort, recipes }); 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. - 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));