diff --git a/.gitignore b/.gitignore index b2ebc657..272912e1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,10 @@ firestore-debug.log firebase-debug.log ui-debug.log -# npm pack artifacts +# npm pack artifacts. The demo folder's pinned build is deliberately tracked. reactfire-*.tgz reactfire.tgz +!ai-studio-demo/reactfire-*.tgz package/ publish.sh unpack.sh diff --git a/ai-studio-demo/.env.example b/ai-studio-demo/.env.example index 7a550fe7..7ea53bc6 100644 --- a/ai-studio-demo/.env.example +++ b/ai-studio-demo/.env.example @@ -7,3 +7,6 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY" # AI Studio automatically injects this at runtime with the Cloud Run service URL. # Used for self-referential links, OAuth callbacks, and API endpoints. APP_URL="MY_APP_URL" + +# Point the app at the local Firebase emulators instead of a real project. +VITE_USE_EMULATORS=true diff --git a/ai-studio-demo/firebase-applet-config.json b/ai-studio-demo/firebase-applet-config.json index c1e104a1..157f558b 100644 --- a/ai-studio-demo/firebase-applet-config.json +++ b/ai-studio-demo/firebase-applet-config.json @@ -1,10 +1,10 @@ { - "projectId": "makersuite-showcase", - "appId": "1:853813963450:web:7096691fd837dc4edc3083", - "apiKey": "AIzaSyDcqYZsSsbU73XCs5fCoJ_RT-cC3V9ClFQ", - "authDomain": "makersuite-showcase.firebaseapp.com", + "projectId": "rxfire-525a3", + "appId": "1:000000000000:web:0000000000000000000000", + "apiKey": "fake-api-key", + "authDomain": "localhost", "firestoreDatabaseId": "ai-studio-afdb1f17-1b5e-4330-a1eb-94f41aea4049", - "storageBucket": "makersuite-showcase.firebasestorage.app", - "messagingSenderId": "853813963450", + "storageBucket": "rxfire-525a3.firebasestorage.app", + "messagingSenderId": "000000000000", "measurementId": "" -} \ No newline at end of file +} diff --git a/ai-studio-demo/package-lock.json b/ai-studio-demo/package-lock.json index 385681a6..6b5fc12c 100644 --- a/ai-studio-demo/package-lock.json +++ b/ai-studio-demo/package-lock.json @@ -21,6 +21,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz", "tailwind-merge": "^3.4.1", "vite": "^6.2.0" }, @@ -5624,6 +5625,24 @@ "node": ">=0.10.0" } }, + "node_modules/reactfire": { + "version": "4.2.6", + "resolved": "file:reactfire-4.2.6-ac3ccf9.tgz", + "integrity": "sha512-bJkIcx9vIOI997X7MaJqSBkYcdiheIOrsez5392lpt2ukvwtYzvGhrwkCHAYJfQIPUqQPSSjOueYO8xpLnsSVQ==", + "license": "MIT", + "dependencies": { + "rxfire": "^6.1.0", + "rxjs": "^6.6.3 || ^7.0.1", + "use-sync-external-store": "^1.2.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || next", + "react": ">=16 || experimental" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5749,6 +5768,25 @@ "fsevents": "~2.3.2" } }, + "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", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6473,6 +6511,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/ai-studio-demo/package.json b/ai-studio-demo/package.json index 98ef996f..4da91f19 100644 --- a/ai-studio-demo/package.json +++ b/ai-studio-demo/package.json @@ -24,6 +24,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz", "tailwind-merge": "^3.4.1", "vite": "^6.2.0" }, diff --git a/ai-studio-demo/reactfire-4.2.6-ac3ccf9.tgz b/ai-studio-demo/reactfire-4.2.6-ac3ccf9.tgz new file mode 100644 index 00000000..69cce28e Binary files /dev/null and b/ai-studio-demo/reactfire-4.2.6-ac3ccf9.tgz differ diff --git a/ai-studio-demo/src/App.tsx b/ai-studio-demo/src/App.tsx index d83c8457..709a9023 100644 --- a/ai-studio-demo/src/App.tsx +++ b/ai-studio-demo/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, Component, ErrorInfo, ReactNode } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo, Component, ErrorInfo, ReactNode } from 'react'; import { onAuthStateChanged, User @@ -7,7 +7,6 @@ import { collection, query, where, - onSnapshot, addDoc, updateDoc, deleteDoc, @@ -25,6 +24,7 @@ import { signIn, logOut } from './firebase'; +import { useFirestoreCollectionData } from 'reactfire'; import { Recipe, Household, @@ -413,6 +413,43 @@ const STOCK_RECIPES: Partial[] = [ // --- Main App --- +// ReactFire hooks cannot be called conditionally, and this query only exists +// once a user is signed in, so the subscription lives in a child that is +// mounted only then. +function HouseholdsFeed({ uid, onData }: { uid: string; onData: (households: Household[]) => void }) { + const householdsQuery = useMemo( + () => query(collection(db, 'households'), where(`members.${uid}`, 'in', ['admin', 'member', 'viewer'])), + [uid], + ); + const { status, data } = useFirestoreCollectionData(householdsQuery, { idField: 'id' }); + + useEffect(() => { + if (status === 'success') { + onData(data as unknown as Household[]); + } + }, [status, data, onData]); + + return null; +} + +// Same constraint as HouseholdsFeed: no query exists until a household is +// selected, and a hook cannot opt out of running. +function RecipesFeed({ householdId, onData }: { householdId: string; onData: (recipes: Recipe[]) => void }) { + const recipesQuery = useMemo( + () => query(collection(db, 'recipes'), where('householdId', '==', householdId)), + [householdId], + ); + const { status, data } = useFirestoreCollectionData(recipesQuery, { idField: 'id' }); + + useEffect(() => { + if (status === 'success') { + onData(data as unknown as Recipe[]); + } + }, [status, data, onData]); + + return null; +} + export default function App() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -537,52 +574,51 @@ export default function App() { return () => unsubscribe(); }, []); - // Fetch Households + // Moved out of the snapshot callback, unchanged: keep the current selection + // if it still exists, otherwise fall back to the first household. Wrapped in + // useCallback with no dependencies because a fresh identity each render + // would re-run HouseholdsFeed's effect in a loop. + const handleHouseholds = useCallback((h: Household[]) => { + setHouseholds(h); + setHouseholdsLoading(false); + if (h.length > 0) { + setSelectedHousehold(prev => { + if (!prev) return h[0]; + const updated = h.find(hh => hh.id === prev.id); + return updated || h[0]; + }); + } else { + setSelectedHousehold(null); + } + }, []); + useEffect(() => { if (!user) { + // Matches the original bail-out exactly: loading cleared, the stale + // household list deliberately NOT cleared. setHouseholdsLoading(false); - return; + } else { + // The original set loading true on each user change before subscribing. + setHouseholdsLoading(true); } - setHouseholdsLoading(true); - const q = query(collection(db, 'households'), where(`members.${user.uid}`, 'in', ['admin', 'member', 'viewer'])); - const unsubscribe = onSnapshot(q, (snapshot) => { - const h = snapshot.docs.map(d => ({ id: d.id, ...d.data() } as Household)); - setHouseholds(h); - setHouseholdsLoading(false); - if (h.length > 0) { - setSelectedHousehold(prev => { - if (!prev) return h[0]; - const updated = h.find(hh => hh.id === prev.id); - return updated || h[0]; - }); - } else { - setSelectedHousehold(null); - } - }, (error) => { - handleFirestoreError(error, OperationType.LIST, 'households'); - }); - return () => unsubscribe(); }, [user]); - // Fetch Recipes + // Moved out of the snapshot callback, comparator unchanged. Sorts a copy: + // the original sorted a fresh array from snapshot.docs.map, and sorting + // ReactFire's data in place would mutate its cached value. + const handleRecipes = useCallback((fetchedRecipes: Recipe[]) => { + const sorted = [...fetchedRecipes].sort((a, b) => { + const timeA = a.createdAt?.toMillis?.() || Date.now(); + const timeB = b.createdAt?.toMillis?.() || Date.now(); + return timeB - timeA; + }); + setRecipes(sorted); + }, []); + useEffect(() => { if (!user || !selectedHousehold) { setRecipes([]); - return; } - const q = query(collection(db, 'recipes'), where('householdId', '==', selectedHousehold.id)); - const unsubscribe = onSnapshot(q, (snapshot) => { - const fetchedRecipes = snapshot.docs.map(d => ({ id: d.id, ...d.data() } as Recipe)); - fetchedRecipes.sort((a, b) => { - const timeA = a.createdAt?.toMillis?.() || Date.now(); - const timeB = b.createdAt?.toMillis?.() || Date.now(); - return timeB - timeA; - }); - setRecipes(fetchedRecipes); - }, (error) => { - handleFirestoreError(error, OperationType.LIST, 'recipes'); - }); - return () => unsubscribe(); }, [user, selectedHousehold]); const handleCreateHousehold = async (name: string) => { @@ -804,11 +840,20 @@ export default function App() { return matchesSearch && matchesCategory; }); + // The feeds must be mounted in every branch a signed-in user can reach + // (this spinner, onboarding, and the main screen), because App renders + // mutually exclusive screens and an unmounted hook is a dead subscription. if (loading || (user && householdsLoading)) { return ( -
- -
+ <> + {user && } + {user && selectedHousehold && ( + + )} +
+ +
+ ); } @@ -837,6 +882,11 @@ export default function App() { if (households.length === 0) { return ( + <> + {user && } + {user && selectedHousehold && ( + + )}
+ ); } return ( + {user && } + {user && selectedHousehold && ( + + )}
{/* Header */} diff --git a/ai-studio-demo/src/firebase.ts b/ai-studio-demo/src/firebase.ts index 60cfe08e..11de9b90 100644 --- a/ai-studio-demo/src/firebase.ts +++ b/ai-studio-demo/src/firebase.ts @@ -1,13 +1,24 @@ import { initializeApp } from 'firebase/app'; -import { getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth'; -import { getFirestore, doc, getDocFromServer } from 'firebase/firestore'; +import { getAuth, GoogleAuthProvider, signInWithPopup, signOut, connectAuthEmulator } from 'firebase/auth'; +import { getFirestore, doc, getDocFromServer, connectFirestoreEmulator } from 'firebase/firestore'; import firebaseConfig from '../firebase-applet-config.json'; +const useEmulators = import.meta.env.VITE_USE_EMULATORS === 'true'; + const app = initializeApp(firebaseConfig); export const db = getFirestore(app, firebaseConfig.firestoreDatabaseId); export const auth = getAuth(app); export const googleProvider = new GoogleAuthProvider(); +// Vite re-evaluates modules on HMR, so connecting twice has to be impossible +// rather than unlikely. +const EMULATOR_SENTINEL = '__heirloomEmulators'; +if (useEmulators && !(EMULATOR_SENTINEL in globalThis)) { + Object.defineProperty(globalThis, EMULATOR_SENTINEL, { value: true }); + connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true }); + connectFirestoreEmulator(db, '127.0.0.1', 8085); +} + export const signIn = () => signInWithPopup(auth, googleProvider); export const logOut = () => signOut(auth); diff --git a/ai-studio-demo/src/main.tsx b/ai-studio-demo/src/main.tsx index 080dac37..8307e8c1 100644 --- a/ai-studio-demo/src/main.tsx +++ b/ai-studio-demo/src/main.tsx @@ -1,10 +1,18 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; +import {AuthProvider, FirebaseAppProvider, FirestoreProvider} from 'reactfire'; import App from './App.tsx'; +import {auth, db} from './firebase.ts'; import './index.css'; createRoot(document.getElementById('root')!).render( - + + + + + + + , ); diff --git a/ai-studio-demo/tsconfig.json b/ai-studio-demo/tsconfig.json index d88f175b..cab60401 100644 --- a/ai-studio-demo/tsconfig.json +++ b/ai-studio-demo/tsconfig.json @@ -10,6 +10,7 @@ "DOM.Iterable" ], "skipLibCheck": true, + "types": ["vite/client", "node", "react", "express"], "moduleResolution": "bundler", "isolatedModules": true, "moduleDetection": "force",