diff --git a/.gitignore b/.gitignore index b2ebc657..00f88779 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ ui-debug.log # npm pack artifacts reactfire-*.tgz reactfire.tgz +# ...except the demo's pinned build, which is its reactfire dependency. +# Regenerate with `npx tsc && npx vite build && npm pack` at the commit in the filename. +!recipe-demo/reactfire-*.tgz package/ publish.sh unpack.sh diff --git a/recipe-demo/package-lock.json b/recipe-demo/package-lock.json index ddf07884..9b70d43d 100644 --- a/recipe-demo/package-lock.json +++ b/recipe-demo/package-lock.json @@ -12,7 +12,8 @@ "firebase": "^12.17.1", "next": "^16.3.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz" }, "devDependencies": { "@types/node": "^24.3.0", @@ -1866,6 +1867,24 @@ "react": "^19.2.8" } }, + "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/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1875,6 +1894,25 @@ "node": ">=0.10.0" } }, + "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", @@ -2048,6 +2086,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "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/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", diff --git a/recipe-demo/package.json b/recipe-demo/package.json index af26e735..c0bfd66f 100644 --- a/recipe-demo/package.json +++ b/recipe-demo/package.json @@ -15,7 +15,8 @@ "firebase": "^12.17.1", "next": "^16.3.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz" }, "devDependencies": { "@types/node": "^24.3.0", diff --git a/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz b/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz new file mode 100644 index 00000000..69cce28e Binary files /dev/null and b/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz differ diff --git a/recipe-demo/src/app/layout.tsx b/recipe-demo/src/app/layout.tsx index de681f56..a1d1cde7 100644 --- a/recipe-demo/src/app/layout.tsx +++ b/recipe-demo/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import '@picocss/pico/css/pico.min.css'; -import { SessionProvider } from '@/lib/session-context'; +import { Providers } from '@/lib/providers'; import { SessionNav } from '@/components/SessionNav'; export const metadata: Metadata = { @@ -13,7 +13,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - +
{children}
-
+ ); diff --git a/recipe-demo/src/components/RecipeBrowser.tsx b/recipe-demo/src/components/RecipeBrowser.tsx index d35f2921..4f80b26d 100644 --- a/recipe-demo/src/components/RecipeBrowser.tsx +++ b/recipe-demo/src/components/RecipeBrowser.tsx @@ -1,13 +1,18 @@ 'use client'; import { useState } from 'react'; +import { useFirestoreCollectionData } from 'reactfire'; import { RecipeList } from './RecipeList'; -import { useRecipes } from '@/lib/use-recipes'; +import { recipeQuery } from '@/lib/recipes'; import { CUISINES, type Cuisine, type Recipe } from '@/lib/types'; export function RecipeBrowser({ initialRecipes }: { initialRecipes: Recipe[] }) { const [cuisine, setCuisine] = useState('all'); - const { recipes, status, error } = useRecipes(cuisine, initialRecipes); + const { data, status } = useFirestoreCollectionData(recipeQuery(cuisine), { + idField: 'id', + initialData: initialRecipes, + }); + const recipes = data as Recipe[]; return ( <> @@ -23,11 +28,7 @@ export function RecipeBrowser({ initialRecipes }: { initialRecipes: Recipe[] }) - {error ? ( -
Could not load recipes: {error.message}
- ) : ( - - )} + ); } diff --git a/recipe-demo/src/components/RecipeCard.tsx b/recipe-demo/src/components/RecipeCard.tsx index 0ca0931a..18912b74 100644 --- a/recipe-demo/src/components/RecipeCard.tsx +++ b/recipe-demo/src/components/RecipeCard.tsx @@ -1,12 +1,12 @@ 'use client'; import { useState } from 'react'; +import { useUser } from 'reactfire'; import { toggleLike } from '@/lib/recipes'; -import { useSession } from '@/lib/session-context'; import type { Recipe } from '@/lib/types'; export function RecipeCard({ recipe }: { recipe: Recipe }) { - const { user } = useSession(); + const { data: user } = useUser(); const [pending, setPending] = useState(false); const liked = user ? recipe.likedBy.includes(user.uid) : false; diff --git a/recipe-demo/src/components/RequireAuth.tsx b/recipe-demo/src/components/RequireAuth.tsx index 3170e13d..14f72e86 100644 --- a/recipe-demo/src/components/RequireAuth.tsx +++ b/recipe-demo/src/components/RequireAuth.tsx @@ -2,26 +2,27 @@ import { usePathname, useRouter } from 'next/navigation'; import { useEffect, type ReactNode } from 'react'; -import { useSession } from '@/lib/session-context'; +import { useSigninCheck } from 'reactfire'; export function RequireAuth({ children }: { children: ReactNode }) { - const { user, status } = useSession(); + const { status, data: signinResult } = useSigninCheck(); + const signedIn = signinResult?.signedIn ?? false; const router = useRouter(); const pathname = usePathname(); useEffect(() => { - // Waiting for 'ready' is what stops a signed-in user being bounced to - // /signin on every hard reload, before onAuthStateChanged has fired. - if (status === 'ready' && !user) { + // Waiting for 'success' is what stops a signed-in user being bounced to + // /signin on every hard reload, before the auth state has resolved. + if (status === 'success' && !signedIn) { router.replace(`/signin?next=${encodeURIComponent(pathname)}`); } - }, [status, user, router, pathname]); + }, [status, signedIn, router, pathname]); if (status === 'loading') { return
Checking your session
; } - if (!user) { + if (!signedIn) { return null; } diff --git a/recipe-demo/src/components/SessionNav.tsx b/recipe-demo/src/components/SessionNav.tsx index 0a3f31da..e2174ab3 100644 --- a/recipe-demo/src/components/SessionNav.tsx +++ b/recipe-demo/src/components/SessionNav.tsx @@ -1,11 +1,11 @@ 'use client'; import Link from 'next/link'; +import { useUser } from 'reactfire'; import { logOut } from '@/lib/session'; -import { useSession } from '@/lib/session-context'; export function SessionNav() { - const { user, status } = useSession(); + const { data: user, status } = useUser(); if (status === 'loading') { return ( diff --git a/recipe-demo/src/lib/providers.tsx b/recipe-demo/src/lib/providers.tsx new file mode 100644 index 00000000..200c5f74 --- /dev/null +++ b/recipe-demo/src/lib/providers.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { AuthProvider, FirebaseAppProvider, FirestoreProvider } from 'reactfire'; +import { app, firestore } from './firebase'; +import { auth } from './session'; + +export function Providers({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/recipe-demo/src/lib/session-context.tsx b/recipe-demo/src/lib/session-context.tsx deleted file mode 100644 index 408a48ce..00000000 --- a/recipe-demo/src/lib/session-context.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import { subscribeToSession, type Session } from './session'; - -const SessionContext = createContext({ user: null, status: 'loading' }); - -export function SessionProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState({ user: null, status: 'loading' }); - - useEffect(() => subscribeToSession(setSession), []); - - return {children}; -} - -export function useSession() { - return useContext(SessionContext); -} diff --git a/recipe-demo/src/lib/session.ts b/recipe-demo/src/lib/session.ts index b98cbbb5..fb61ce29 100644 --- a/recipe-demo/src/lib/session.ts +++ b/recipe-demo/src/lib/session.ts @@ -1,6 +1,6 @@ 'use client'; -import { getAuth, connectAuthEmulator, onAuthStateChanged, signInWithEmailAndPassword, signOut, type User } from 'firebase/auth'; +import { getAuth, connectAuthEmulator, signInWithEmailAndPassword, signOut } from 'firebase/auth'; import { app, useEmulators } from './firebase'; export const auth = getAuth(app); @@ -12,15 +12,6 @@ if (useEmulators && !(EMULATOR_SENTINEL in globalThis)) { connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true }); } -export interface Session { - user: User | null; - status: 'loading' | 'ready'; -} - -export function subscribeToSession(onChange: (session: Session) => void) { - return onAuthStateChanged(auth, (user) => onChange({ user, status: 'ready' })); -} - export function signIn(email: string, password: string) { return signInWithEmailAndPassword(auth, email, password); } diff --git a/recipe-demo/src/lib/use-recipes.ts b/recipe-demo/src/lib/use-recipes.ts deleted file mode 100644 index 0bea99ec..00000000 --- a/recipe-demo/src/lib/use-recipes.ts +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import { onSnapshot } from 'firebase/firestore'; -import { useEffect, useRef, useState } from 'react'; -import { recipeQuery, toRecipes } from './recipes'; -import type { Cuisine, Recipe } from './types'; - -export type FeedStatus = 'loading' | 'ready' | 'error'; - -/** - * Takes over from the server-rendered list: seeds with what the server already - * fetched, then switches to a live subscription without a loading flash. - */ -export function useRecipes(cuisine: Cuisine | 'all', initialRecipes: Recipe[]) { - // Which filter the current `recipes` describe. It starts as the cuisine the - // server rendered and moves on with every snapshot, so switching away and - // back still shows loading rather than the previous filter's list. - const loadedCuisine = useRef(cuisine); - const [recipes, setRecipes] = useState(initialRecipes); - const [status, setStatus] = useState('ready'); - const [error, setError] = useState(); - - useEffect(() => { - if (cuisine !== loadedCuisine.current) { - setStatus('loading'); - } - - return onSnapshot( - recipeQuery(cuisine), - (snapshot) => { - setRecipes(toRecipes(snapshot)); - loadedCuisine.current = cuisine; - setStatus('ready'); - setError(undefined); - }, - (err) => { - setError(err); - setStatus('error'); - }, - ); - }, [cuisine]); - - return { recipes, status, error }; -}