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
11 changes: 9 additions & 2 deletions ai-studio-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ View your app in AI Studio: https://ai.studio/apps/bundled/heirloom_recipes

1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
2. Copy `.env.example` to `.env.local`. Vite only loads `.env.local`, so the
example file on its own has no effect.
3. Set `GEMINI_API_KEY` in `.env.local` to your Gemini API key.
4. Leave `VITE_USE_EMULATORS=true` to run against the local Firebase emulators,
which is what this branch is set up for. Start them from the repository root
with `firebase emulators:start` before running the app. Setting it to `false`
points the app at the real project named in `src/firebaseConfig.ts`, which
needs credentials this branch does not carry.
5. Run the app:
`npm run dev`
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": ""
}
}
2,630 changes: 1,128 additions & 1,502 deletions ai-studio-demo/package-lock.json

Large diffs are not rendered by default.

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": "4.2.6-exp.ac3ccf9",
"tailwind-merge": "^3.4.1",
"vite": "^6.2.0"
},
Expand Down
139 changes: 98 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 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 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,53 @@ 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.
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.
// useCallback is load-bearing HERE and not on handleHouseholds: this one
// allocates a fresh array every call, so setRecipes re-renders every time,
// and a fresh identity each render re-runs RecipesFeed's effect in a loop.
// Measured unmemoized: thousands of runs in five seconds and still climbing.
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 +842,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 +884,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 +925,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"],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
Expand Down
Loading