refactor: recipe demo app rebuilt on AngularFire - #3753
Open
armando-navarro wants to merge 25 commits into
Open
Conversation
The like counter moves by exactly one, and only in the commit that adds or removes the caller's own like document. Each of the two rules carries half of that check: the recipe's rule looks at the like document before and after, and the like document's rule looks at the counter. Either half alone is walked around by resetting the other side in a separate write. A recipe create must name the caller as owner, start the counter at zero, and carry exactly the seven expected fields with the expected types. Recipes are world readable and only the owner may delete. The seed script exits when its credentials are missing, since signing in with undefined reads like a project misconfiguration rather than an unset environment variable.
The CLI scaffold predates the .prettierrc in this app, so four generated files disagreed with it on trailing commas and on arrowParens: avoid. Formatting only, no behavior change.
Four InjectionTokens carry the Firebase handles so components inject them
instead of importing shared singleton instances from a helper file. The
shared app.config provides Firestore and Auth, which derive from whichever
FIREBASE_APP the platform supplies. The client config initializes a normal
app plus App Check and adds FIREBASE_AI, the server config uses
initializeServerApp and deletes the app on DestroyRef so each render
releases its handle. FIREBASE_AI is client only, so server-side injection
sites need { optional: true }.
FIREBASE_AI names the Agent Platform backend explicitly. Plain getAI()
selects the Gemini Developer API, whose separate prepay balance cannot be
funded by Google Cloud credit. Agent Platform bills as ordinary Cloud usage
and defaults to the global location, which is where this app's model runs.
The browser mirrors the ID token into a __session cookie and the server passes it to initializeServerApp, so per-user UI is in the served HTML instead of appearing only after hydration. An app initializer waits for authStateReady() before rendering. Without it a route with no Firestore read renders signed out despite a valid token, and the home page only renders signed in by winning a race against its own recipes read.
AuthStore exposes the signed-in user as a signal and wraps the sign-in, account-creation, and sign-out calls. The sign-in page takes an email and password and either signs in or creates an account. Failures appear inline, and a wrong email and a wrong password read the same, so the form never reveals which half failed. The header swaps between a sign-in link and the signed-in email with a sign-out button, replacing the scaffold placeholder and its inline styles.
The scaffold shipped an empty allowedHosts list, so every request failed host validation and the server quietly served the client-side page instead. Listing localhost restores server rendering, and an unknown host now returns a 400 rather than a silent downgrade. A deployed host has to be added to this list too.
Every component here sets OnPush by hand, so the generator should produce it rather than leaving it to be remembered each time.
The store reads through a Firestore converter and wraps the query in a resource that reads once on the server, because a listener would never settle and the render has to finish, then subscribes for live updates in the browser. The server hands its rendered list to the browser, which uses it until the listener responds. Without that the first client render painted an empty list over the server's, blanking the page for roughly half a second. Reads and writes do not share a model, and a converter is typed with one model for both directions, so each direction gets a converter over a shared core of fields. CUISINES is typed readonly string[] rather than a const tuple, so a plain string can be checked against it. Each option binds its own selected state rather than binding value on the select, which is applied before the loop creates the options and silently dropped any non-default selection on a client-side navigation. The filter controls sit in a search landmark and the cards are list items, so the collection is navigable.
A like writes a document under users/{uid}/likes and moves the recipe's
likeCount in the same batch, so the two cannot disagree. The liked set
arrives through a resource with a stream loader. Undefined params leave
it idle on the server, and signing out sends null instead, because
Angular does not abort the previous load when params go undefined, which
would leave the listener open.
An unlike skips the decrement when the count is already zero, because
the rules reject a negative count and would fail the whole batch.
The button waits for the first likes snapshot before enabling, since
liking earlier would add a second count to an already liked recipe, and
carries aria-label so its name does not change with the count.
Delete renders only on cards whose createdBy matches the signed-in uid, and the deployed rule refuses a delete from anyone else. A confirmation modal opens when delete is clicked. A failed delete names the recipe in the same alert paragraph the list and like errors use. Each action clears the other's message first, since that paragraph shows one message at a time and a stale like error would otherwise hide the delete that just failed. Other users' like documents are not cleaned up when a recipe goes. A real app would clear them from a Cloud Functions trigger.
The route guard on /create-recipe always allows the server render, because the server has no signed-in user and deciding there would send every visitor an HTTP redirect to /signin. The server returns the page shell and the browser then chooses between showing the page and redirecting, after Firebase has restored any saved session. The guard waits for that with authStateReady(). Reading the currentUser signal before the restore finishes would see null and send an already signed-in visitor to /signin instead of the page they asked for. The guard also puts the requested path in a returnUrl parameter so sign-in returns there. Gemini returns only the four fields the response schema asks for, with no createdBy, and toRecipe fills createdBy from whatever object it is given, so the validated result carries an empty string there. The draft is therefore assembled field by field with createdBy taken from the signed-in user, since the create rule requires it to equal the requesting uid. This route never shows the recipe list, so the resource now idles until a page asks for it rather than making every server render here wait on a Firestore read it discards. Failures become readable messages instead of raw SDK text.
App Hosting fronts the app with a proxy that attaches the full X-Forwarded-* set. Angular's server engine trusts only x-forwarded-host and x-forwarded-proto by default, and one untrusted header makes it drop the render and return the client-side shell at HTTP 200. The deployed hostnames join allowedHosts because the engine validates both Host and X-Forwarded-Host against that list and answers an unlisted host with a 400. The four listed match what the Firebase CLI configures for an Angular backend.
The guard returned true on the server and left the browser to redirect, so an anonymous request for a protected route got a rendered page and a bounce a moment later. Since the server learned who the visitor is, it can answer properly. Removing the platform branch lets the guard run on both platforms. Angular SSR notices the router ended somewhere other than the requested URL, abandons the render, and returns a 302 with the location, so an anonymous request now gets one redirect to the sign-in page instead of a page nobody can use.
Commit b83343d is the latest canary as of this writing.
…ders 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.
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.
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.
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the same application as the
recipe-demobranch, with its Firebase layer rebuilt on AngularFire.It is 8 commits, 13 files, and 249 lines added against 262 removed. Each commit converts one area, so the diff can be read a commit at a time.
What AngularFire replaces
In plain terms
In detail
firebase-tokens.tsholding fourInjectionTokens, wired through five hand-written provider objects across three config files, becomes sixprovide*calls. The file is deleted.onAuthStateChangedsubscription, the signal it pushed into, the teardown that unsubscribed it and awhenAuthResolved()wrapper become onetoSignal(authState(auth), { initialValue: null }).resourcewhose loader openedonSnapshotby hand, pushed snapshots and errors into a signal and registered the unsubscribe function as anabortlistener on the loader'sabortSignal, becomes anrxResourceovercollectionData.deleteAppin aDestroyRef.onDestroyblock becomes areleaseOnDerefproperty, following the pattern indocs/auth.md.provideAppChecksets it already.Across the four files that hold the Firebase layer, the base branch is 397 lines and this branch is 364.
What it does not change
These are invisible in a diff, and each is a place a reader could otherwise draw a stronger conclusion than the change supports.
TransferStatehandoff carrying the recipe list from server to browser. Angular's automatic state transfer coversHttpClient, which Firestore never touches, and AngularFire adds nothing here, so the same hand-written code is needed on both branches.server.ts, including the__sessioncookie plumbing behind the signed-in server render.beforeAuthStateChanged, which has no observable form in the library and stays imported fromfirebase/authon both branches.hasAuthIdTokentype predicate that narrowsREQUEST_CONTEXT.What it gains
Less code is the visible part. These are the things the app stops being responsible for.
Teardown becomes the framework's job. Two subscriptions the base branch has to store and unsubscribe itself are now torn down by Angular. There is no unsubscribe function to keep hold of, so there is none to forget.
Initialization order comes free.
provideFirestorelists Auth and App Check among its own dependencies, with the library's comment reading "Firestore+Auth work better if Auth is loaded first", so Firestore resolves after Auth without the app arranging it. The base branch owns that ordering by hand.The error path arrives with the read. The base branch passes an explicit error callback to
onSnapshotand pushes the error into a signal itself.rxResourcesurfaces it without that.The App Check debug token is handled more broadly than the hand-written version. The base branch sets the flag for an exact
localhostmatch.provideAppChecksets it whenever the app is in development mode or the hostname islocalhost,0.0.0.0or127.0.0.1. Worth an honest caveat: this app'sangular.jsonallows onlylocalhost, so the wider coverage cannot actually be observed here.The server's wait for auth stops being explicit. The base branch waits with an app initializer calling
authStateReady(). HereauthStateis zone-wrapped, and that wrapper registers the pending task that holds the server render open, so the initializer is deleted. One precision worth stating, because it is easy to over-credit: the recipe list's own server wait comes from Angular'srxResource, not from AngularFire. Auth is the place where AngularFire demonstrably carries the wait in this app.What it costs
collectionData(query, { idField: 'id' })returns untyped documents, so the store crosses them to the app'sRecipetype with two casts and nothing checks the shape at runtime. The base branch used aFirestoreDataConverter.EnvironmentInjectorand wrap their calls inrunInInjectionContext. Nine call sites, six in the recipe store and three in the auth store. Template event handlers are not injection contexts, which is why the write paths need this and the base branch does not.Running it
Deployed instance: https://angularfire--recipe-demo-97859.us-central1.hosted.app
recipe-demo/README.mdon this branch covers the project setup, the run commands, the design decisions that are hard to deduce from the code, and the gaps a production app would close that this one deliberately leaves open.What happens to this pull request
This should never be merged. It exists as a reference demo.