Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ai-studio-demo/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 7 additions & 7 deletions ai-studio-demo/firebase-applet-config.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
}
47 changes: 47 additions & 0 deletions ai-studio-demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ai-studio-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Binary file added ai-studio-demo/reactfire-4.2.6-ac3ccf9.tgz
Binary file not shown.
137 changes: 96 additions & 41 deletions ai-studio-demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -7,7 +7,6 @@ import {
collection,
query,
where,
onSnapshot,
addDoc,
updateDoc,
deleteDoc,
Expand All @@ -25,6 +24,7 @@ import {
signIn,
logOut
} from './firebase';
import { useFirestoreCollectionData } from 'reactfire';
import {
Recipe,
Household,
Expand Down Expand Up @@ -413,6 +413,43 @@ const STOCK_RECIPES: Partial<Recipe>[] = [

// --- 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<User | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 (
<div className="h-screen flex items-center justify-center bg-stone-50">
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
</div>
<>
{user && <HouseholdsFeed uid={user.uid} onData={handleHouseholds} />}
{user && selectedHousehold && (
<RecipesFeed householdId={selectedHousehold.id} onData={handleRecipes} />
)}
<div className="h-screen flex items-center justify-center bg-stone-50">
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
</div>
</>
);
}

Expand Down Expand Up @@ -837,6 +882,11 @@ export default function App() {

if (households.length === 0) {
return (
<>
{user && <HouseholdsFeed uid={user.uid} onData={handleHouseholds} />}
{user && selectedHousehold && (
<RecipesFeed householdId={selectedHousehold.id} onData={handleRecipes} />
)}
<div className="min-h-screen bg-[#f5f5f0] flex flex-col items-center justify-center p-6 font-serif">
<motion.div
initial={{ opacity: 0, y: 20 }}
Expand Down Expand Up @@ -873,11 +923,16 @@ export default function App() {
</button>
</motion.div>
</div>
</>
);
}

return (
<ErrorBoundary>
{user && <HouseholdsFeed uid={user.uid} onData={handleHouseholds} />}
{user && selectedHousehold && (
<RecipesFeed householdId={selectedHousehold.id} onData={handleRecipes} />
)}
<div className="min-h-screen bg-[#f5f5f0] dark:bg-stone-950 text-stone-800 dark:text-stone-200 font-sans pb-24 transition-colors duration-300">
<div id="main-content">
{/* Header */}
Expand Down
15 changes: 13 additions & 2 deletions ai-studio-demo/src/firebase.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
10 changes: 9 additions & 1 deletion ai-studio-demo/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<App />
<FirebaseAppProvider firebaseApp={db.app}>
<FirestoreProvider sdk={db}>
<AuthProvider sdk={auth}>
<App />
</AuthProvider>
</FirestoreProvider>
</FirebaseAppProvider>
</StrictMode>,
);
1 change: 1 addition & 0 deletions ai-studio-demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"DOM.Iterable"
],
"skipLibCheck": true,
"types": ["vite/client", "node", "react", "express"],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
Expand Down
Loading