Skip to content

refactor: recipe demo app rebuilt on AngularFire - #3753

Open
armando-navarro wants to merge 25 commits into
angular:mainfrom
armando-navarro:recipe-demo-framework
Open

refactor: recipe demo app rebuilt on AngularFire#3753
armando-navarro wants to merge 25 commits into
angular:mainfrom
armando-navarro:recipe-demo-framework

Conversation

@armando-navarro

Copy link
Copy Markdown
Collaborator

This is the same application as the recipe-demo branch, 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

  • The app no longer wires up Firebase services by hand. The base branch builds four wiring objects and threads them through three config files. This branch declares each Firebase product it uses in a single line.
  • Watching who is signed in used to be a subscription the app opened, held onto and closed. It is now one line, and Angular closes it.
  • Reading the live recipe list used to be a listener the app managed itself, with its own success path, error path and cleanup. It is now one declaration.
  • Disposing of the per-request Firebase instance on the server used to be an explicit delete call. It is now a property on the call that creates the instance.
  • One workaround the base branch needs for local development goes away, because the library already does it.

In detail

  • A firebase-tokens.ts holding four InjectionTokens, wired through five hand-written provider objects across three config files, becomes six provide* calls. The file is deleted.
  • An onAuthStateChanged subscription, the signal it pushed into, the teardown that unsubscribed it and a whenAuthResolved() wrapper become one toSignal(authState(auth), { initialValue: null }).
  • A resource whose loader opened onSnapshot by hand, pushed snapshots and errors into a signal and registered the unsubscribe function as an abort listener on the loader's abortSignal, becomes an rxResource over collectionData.
  • An explicit deleteApp in a DestroyRef.onDestroy block becomes a releaseOnDeref property, following the pattern in docs/auth.md.
  • The App Check debug-token flag the base branch sets by hand on localhost goes away, because provideAppCheck sets 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.

  • The hand-rolled TransferState handoff carrying the recipe list from server to browser. Angular's automatic state transfer covers HttpClient, which Firestore never touches, and AngularFire adds nothing here, so the same hand-written code is needed on both branches.
  • All of server.ts, including the __session cookie plumbing behind the signed-in server render.
  • beforeAuthStateChanged, which has no observable form in the library and stays imported from firebase/auth on both branches.
  • The hasAuthIdToken type predicate that narrows REQUEST_CONTEXT.
  • The signed-in server render itself, which the base branch already had. What the diff shows is that feature being converted, not gained.

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. provideFirestore lists 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 onSnapshot and pushes the error into a signal itself. rxResource surfaces 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 localhost match. provideAppCheck sets it whenever the app is in development mode or the hostname is localhost, 0.0.0.0 or 127.0.0.1. Worth an honest caveat: this app's angular.json allows only localhost, 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(). Here authState is 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's rxResource, not from AngularFire. Auth is the place where AngularFire demonstrably carries the wait in this app.

What it costs

  • The recipe read becomes unvalidated. collectionData(query, { idField: 'id' }) returns untyped documents, so the store crosses them to the app's Recipe type with two casts and nothing checks the shape at runtime. The base branch used a FirestoreDataConverter.
  • Every AngularFire-wrapped call needs an injection context, so the two stores gain an injected EnvironmentInjector and wrap their calls in runInInjectionContext. 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.md on 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant