Skip to content

demo(ai-studio): swap the live queries for ReactFire - #800

Open
tyler-reitz wants to merge 4 commits into
ai-studio-demofrom
ai-studio-demo-framework
Open

demo(ai-studio): swap the live queries for ReactFire#800
tyler-reitz wants to merge 4 commits into
ai-studio-demofrom
ai-studio-demo-framework

Conversation

@tyler-reitz

Copy link
Copy Markdown
Contributor

Swaps the AI Studio export's two live Firestore subscriptions for ReactFire hooks. The diff against ai-studio-demo is the deliverable: same app, same behavior, nothing added, the 1426-line App.tsx kept as one file per the requirements doc.

Not a merge candidate. Comparison artifact, like its base.

What converted and what did not

Site What it is Converted?
onSnapshot households live household list for the signed-in user YesHouseholdsFeed
onSnapshot recipes live recipes for the selected household YesRecipesFeed
onAuthStateChanged on-sign-in lifecycle: profile creation, 24h cleanup No, by decision: it is a routine, not a state binding. useUser and useSigninCheck therefore never appear in this demo; project 1 covers both
getDocs ×4 imperative deletes (cleanup routine, cascade delete) No: event-handler mutations are outside a data-binding library's remit, same as toggleLike in project 1. Zero diff is the honest output

The headline: hooks cannot be conditional, and one mount point is not enough

Both original effects bail out early (if (!user) return, if (!user || !selectedHousehold) return). A hook cannot opt out of running, so each subscription became a child component mounted behind its guard, pointing at #346 (disabling queries, 8 reactions) and #463 (nullable refs, 18 reactions).

It is worse than one extraction per subscription: App renders four mutually exclusive screens, and the feeds must be mounted in every branch a signed-in user can reach, three of the four. Mounted only in the main return, the app deadlocks on onboarding: households is empty, so the onboarding screen renders, the feed never mounts, and nothing can ever populate households; verified by the onboarding-exit check, which hangs in that configuration and passes in this one. The vanilla effect ran regardless of which screen rendered. Rebuilding that property by hand is part of the swap's cost.

The measurement

+106 / -42 across 3 files (App.tsx +96/-41, main.tsx +9/-1, package.json +1), measured from the repoint commit so the config changes are not counted against the swap. The net +64 lines was the predicted outcome, stated in the spec up front: for an app shaped like this one, ReactFire pushes generated code toward more structure, not less. On the token question the doc actually asks: extrapolating from this diff, a generator writing this app with ReactFire available would have spent more tokens on the data layer, not fewer. That is an estimate derived from the line delta, not a generation measurement.

Other findings

  • The error path is lost. useObservable re-throws unconditionally, so both handleFirestoreError callbacks are gone: Firestore errors now reach the app's ErrorBoundary from the main screen, and nothing at all from the spinner and onboarding screens. Same finding as project 1; fix: surface observable errors via status instead of re-throwing #735 (v5-only) fixes exactly this.
  • One delta favours ReactFire and is a side effect of a known leak, not a feature: previously viewed households stay subscribed because the observable cache never evicts (The observable cache leaks subscriptions and is shared across SSR requests #790), so switching back renders instantly and stays synced while unselected, where vanilla tears the listener down. Disclosed here so a snappier-feeling demo is not read as a capability.
  • Unchanged from vanilla, same class as the demo(recipe): vanilla recipe app on the Firebase JS SDK #797 review's finding 2: one recipe document missing an array field (instructions) blanks the whole app. Reproduced accidentally during verification with a malformed probe document; both halves crash identically.
  • The sort moves out of the snapshot callback and must operate on a copy, since sorting ReactFire's data in place would mutate its cached value.

Verification

Against the Firebase emulators, in the browser, each check paired with a control:

Check Result
Existing user renders households and recipes Same as vanilla
Onboarding exit (the deadlock case) Fresh uid creates a household through the UI and leaves onboarding, no reload
Live: external rename of the selected household Header updates, no reload
Live: external recipe write Appears sorted first, no reload
Sort createdAt descending across probe and seeded recipes
Distinguishability No-household (onboarding) vs empty household (empty grid) remain distinct states, which the rejected sentinel-query design would have merged
Mutation controls idField broken → blank page with a where() error; recipes handler neutered → empty grid. Both restored and re-verified
tsc and vite build Clean, typecheck first proven able to fail

Unverified: signInWithPopup (needs a focused window and a human; sessions were established via signInWithCredential against the Auth emulator), Gemini generation (needs the AI Studio key), and the export's own firestore.rules: every check ran under the repo's open emulator rules, so the app's real security model is unexercised on both halves equally.

…itch

The export hard-codes the Google-internal project makersuite-showcase with
a named Firestore database, so it cannot run anywhere else. Repoints the
config and adds VITE_USE_EMULATORS so the same build serves local
verification and a real project later. The named database id is kept:
the emulator serves named databases (verified 2026-08-20 by write and
isolation, since a read probe returns 200 from any database name).

tsconfig gains a types array because the export never referenced
vite/client, so import.meta.env did not typecheck; the other three
entries keep the ambient types the no-types default was already loading.

Verified against the emulators: console sign-in with an unsigned Google
credential lands on onboarding, and the negative control (emulator down)
surfaces testConnection's offline error in the console, which stops on
restart. The UI is not the signal for that control; the sign-in screen
renders normally either way.

Separate from the ReactFire swap so that diff shows only the swap.
Providers first with nothing consuming them, so any breakage here is
attributable before a conversion is layered on top. The dependency is the
same pinned build recipe-demo uses (main at ac3ccf9), extracted from git
with matching hashes, and the gitignore negation is proven both ways: the
demo tarball is visible to git while a root-level one stays ignored.

Verified in the browser against the emulators: sign-in, household
creation, onboarding exit and the seeded recipe grid all behave exactly
as the unwrapped app, with no console errors. npm ls react shows one
react@19.2.4 with every consumer deduped to it.
The hook cannot be called conditionally and the query needs a signed-in
user, so the subscription lives in HouseholdsFeed, mounted behind a user
guard. One mount point is not enough: App renders four mutually exclusive
screens, and the feed must be mounted in every one a signed-in user can
reach (spinner, onboarding, main), or the app deadlocks on onboarding
with the subscription unmounted and households stuck empty.

The selection logic moves into a useCallback handler unchanged. The
!user bail-out and the loading-true-on-user-change behavior are
reproduced exactly, including leaving a stale household list on sign-out
as the original did.

The onSnapshot error callback is gone: useObservable re-throws rather
than surfacing an error status, so Firestore errors reach the app's
ErrorBoundary (main screen) or nothing (spinner and onboarding screens)
instead of handleFirestoreError.

Verified against the emulators in the browser: existing user renders,
fresh user exits onboarding by creating a household through the UI (the
deadlock case), an external rename of the selected household streams into
the header with no reload, and the idField mutation control blanks the
page with a where() error, proving the feed is what drives the list.
Both subscriptions now come from ReactFire. Neither hook can be called
conditionally, so each lives in a child component mounted only when its
precondition holds, in every return branch a signed-in user can reach:
the surrounding selection logic and sort are unchanged and merely moved
out of the snapshot callbacks. The sort operates on a copy, since sorting
ReactFire's data in place would mutate its cached value. The now-unused
onSnapshot import is dropped.

The onSnapshot error callbacks are gone. useObservable re-throws rather
than surfacing an error status, so Firestore errors now reach the app's
ErrorBoundary instead of handleFirestoreError.

Verified against the emulators in the browser: switching households
switches recipe lists, an external write appears sorted first with no
reload, createdAt-descending order holds across probe and seeded
recipes, no-household-selected (onboarding) stays distinguishable from
household-with-no-recipes (empty grid), and the mutation control
(dropping the handler's data) empties the grid, restored and re-verified
after.
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