diff --git a/websites/recipe-website/common/components/SearchForm/SearchContext.tsx b/websites/recipe-website/common/components/SearchForm/SearchContext.tsx index d83f4276..dec55190 100644 --- a/websites/recipe-website/common/components/SearchForm/SearchContext.tsx +++ b/websites/recipe-website/common/components/SearchForm/SearchContext.tsx @@ -2,54 +2,99 @@ import { createContext, + useCallback, useContext, - useState, useMemo, - useEffect, - ReactNode, + useState, + useSyncExternalStore, + type ReactNode, } from "react"; -import { useInfiniteQuery, QueryStatus } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Document } from "flexsearch"; -import { - MassagedRecipeEntry, - ReadRecipeIndexResult, -} from "../../controller/data/read"; -import { useFlexSearch } from "./useFlexSearch"; - -const searchOptions = { - merge: true, - enrich: true, -}; - -async function fetchRecipes({ - pageParam, -}: { - pageParam: number; -}): Promise { - const res = await fetch("/search/page/" + pageParam); - const json = (await res.json()) as ReadRecipeIndexResult; - return json; +import IdxDB from "flexsearch/db/indexeddb"; +import { MassagedRecipeEntry } from "../../controller/data/read"; + +// --- sessionStorage-backed state via useSyncExternalStore --- + +const SESSION_EVENT = "recipe-search-storage"; +const QUERY_KEY = "search-query"; +const INPUT_KEY = "search-inputValue"; + +function subscribeSession(callback: () => void) { + window.addEventListener(SESSION_EVENT, callback); + return () => window.removeEventListener(SESSION_EVENT, callback); +} + +function readSession(key: string) { + return sessionStorage.getItem(key) ?? ""; +} + +function writeSession(key: string, value: string) { + if (value) { + sessionStorage.setItem(key, value); + } else { + sessionStorage.removeItem(key); + } + window.dispatchEvent(new Event(SESSION_EVENT)); +} + +const getQuerySnapshot = () => readSession(QUERY_KEY); +const getInputSnapshot = () => readSession(INPUT_KEY); +const getServerSnapshot = () => ""; + +// --- localStorage-backed last-populated index version --- +// Lives in localStorage (not sessionStorage) so it persists alongside the +// IndexedDB-cached FlexSearch index across sessions. + +const LOCAL_EVENT = "recipe-search-local"; +const POPULATED_VERSION_KEY = "search-populated-version"; + +function subscribeLocal(callback: () => void) { + window.addEventListener(LOCAL_EVENT, callback); + window.addEventListener("storage", callback); + return () => { + window.removeEventListener(LOCAL_EVENT, callback); + window.removeEventListener("storage", callback); + }; +} + +function getPopulatedVersion() { + return localStorage.getItem(POPULATED_VERSION_KEY); +} + +function writePopulatedVersion(version: string) { + localStorage.setItem(POPULATED_VERSION_KEY, version); + window.dispatchEvent(new Event(LOCAL_EVENT)); } +const getPopulatedVersionServerSnapshot = () => null; + +// --- data fetchers --- + +async function fetchAllRecipes(): Promise { + const res = await fetch("/search/all"); + return res.json(); +} + +async function fetchIndexVersion(): Promise { + const res = await fetch("/search/version"); + const { version } = (await res.json()) as { version: string }; + return version; +} + +// --- context --- + export interface SearchContextValue { - // State query: string; inputValue: string | undefined; searchedRecipes: MassagedRecipeEntry[] | undefined; allRecipes: MassagedRecipeEntry[]; - seeking: number | undefined; - - // Query state (from TanStack Query) - hasNextPage: boolean; + indexReady: boolean; isFetching: boolean; - isFetchingNextPage: boolean; - status: QueryStatus; + status: "pending" | "success" | "error"; error: Error | null; - - // Actions setInputValue: (value: string) => void; submitSearch: (query: string) => void; - loadMore: () => void; } const SearchContext = createContext(undefined); @@ -59,140 +104,148 @@ export interface SearchProviderProps { } export function SearchProvider({ children }: SearchProviderProps) { - // FlexSearch index - const [index] = useState(() => { - return new Document({ - preset: "default", - tokenize: "forward", - document: { store: true, id: "slug", index: ["name", "ingredients"] }, - }); - }); + // Stable Document instance + const [index] = useState( + () => + new Document({ + preset: "default", + tokenize: "forward", + document: { store: true, id: "slug", index: ["name", "ingredients"] }, + }), + ); - // Search state - initialize from sessionStorage - const [query, setQuery] = useState(() => { - if (typeof window !== "undefined") { - return sessionStorage.getItem("search-query") || ""; - } - return ""; + // Step 1: mount IndexedDB persistent storage + const { data: mountedIndex } = useQuery({ + queryKey: ["search-index-mount"], + queryFn: async () => { + await index.mount(new IdxDB("recipe-search")); + return index; + }, + staleTime: Infinity, + gcTime: Infinity, }); - const [inputValue, setInputValue] = useState(() => { - if (typeof window !== "undefined") { - const stored = sessionStorage.getItem("search-inputValue"); - return stored || undefined; - } - return undefined; + const indexReady = !!mountedIndex; + + // Step 2: check the current server-side index version. Cheap stat-based + // endpoint; always revalidated so a fresh page load sees fresh data. + const { data: serverVersion } = useQuery({ + queryKey: ["search-index-version"], + queryFn: fetchIndexVersion, + staleTime: 0, + gcTime: Infinity, }); - const [seeking, setSeeking] = useState(); - // TanStack Query for infinite pagination - NO initialData - const infiniteQuery = useInfiniteQuery({ + // Last version we successfully populated into the IndexedDB-cached index. + const populatedVersion = useSyncExternalStore( + subscribeLocal, + getPopulatedVersion, + getPopulatedVersionServerSnapshot, + ); + + // If the server version matches what we last populated, the + // IndexedDB-cached FlexSearch index is already current — skip the + // recipes fetch and the populate step entirely. + const needsRefetch = + serverVersion !== undefined && serverVersion !== populatedVersion; + + // Step 3: fetch all recipes (only when the cached index is stale) + const recipesQuery = useQuery({ queryKey: ["recipes"], - queryFn: fetchRecipes, - initialPageParam: 1, - getNextPageParam: (lastPage, _allPages, lastPageParam) => { - if (!lastPage.more) { - return undefined; - } - return lastPageParam + 1; - }, - getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { - if (firstPageParam <= 1) { - return undefined; - } - return firstPageParam - 1; - }, + queryFn: fetchAllRecipes, + enabled: needsRefetch, }); - - const { - data, - error, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - status, - } = infiniteQuery; - - // Flatten all recipes from pages const allRecipes = useMemo( - () => (data?.pages || []).flatMap(({ recipes }) => recipes), - [data?.pages], + () => recipesQuery.data ?? [], + [recipesQuery.data], ); - // Update FlexSearch index when recipes change - useEffect(() => { - if (index && allRecipes) { + // Step 4: populate the index with fresh recipes, then commit. + // Keyed on dataUpdatedAt so it only re-runs on an actual refetch. + // After commit, record the populated version so future loads with an + // unchanged server version can skip the fetch entirely. + useQuery({ + queryKey: ["search-index-populate", recipesQuery.dataUpdatedAt], + queryFn: async () => { for (const recipe of allRecipes) { - index.update(recipe); + mountedIndex!.update(recipe); } - } - }, [index, allRecipes]); + await mountedIndex!.commit(); + if (serverVersion) writePopulatedVersion(serverVersion); + return recipesQuery.dataUpdatedAt; + }, + enabled: !!mountedIndex && allRecipes.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }); - // FlexSearch integration - const searchResults = useFlexSearch(query, index, allRecipes, searchOptions); + // sessionStorage-backed query / inputValue + const query = useSyncExternalStore( + subscribeSession, + getQuerySnapshot, + getServerSnapshot, + ); + const rawInput = useSyncExternalStore( + subscribeSession, + getInputSnapshot, + getServerSnapshot, + ); + const inputValue = rawInput || undefined; - const searchedRecipes = useMemo(() => { - if (searchResults && "map" in searchResults) { - return (searchResults as unknown as { doc: MassagedRecipeEntry }[]).map( + // Step 4: run the search. Key on query only — intentionally NOT on + // indexVersion, so an in-flight result set isn't yanked out from under + // the user when a background re-index commits. New searches after that + // point still hit the fresh index because FlexSearch reads live state + // from the same mountedIndex instance. + const { data: searchedRecipes } = useQuery({ + queryKey: ["search", query], + queryFn: async () => { + const raw = await Promise.resolve( + mountedIndex!.search(query, { merge: true, enrich: true }), + ); + return (raw as unknown as { doc: MassagedRecipeEntry }[]).map( ({ doc }) => doc, ); - } - }, [searchResults]); - - // Handle seeking (load more when searching) - useEffect(() => { - if ( - seeking !== undefined && - searchedRecipes && - searchedRecipes.length <= seeking - ) { - fetchNextPage(); - } else { - setSeeking(undefined); - } - }, [seeking, searchedRecipes, fetchNextPage]); - - // Persist query and inputValue to sessionStorage - useEffect(() => { - if (typeof window !== "undefined") { - if (query) { - sessionStorage.setItem("search-query", query); - } else { - sessionStorage.removeItem("search-query"); - } - } - }, [query]); - - useEffect(() => { - if (typeof window !== "undefined") { - if (inputValue) { - sessionStorage.setItem("search-inputValue", inputValue); - } else { - sessionStorage.removeItem("search-inputValue"); - } - } - }, [inputValue]); - - // Context value - const value: SearchContextValue = { - query, - inputValue, - searchedRecipes, - allRecipes, - seeking, - hasNextPage: hasNextPage || false, - isFetching, - isFetchingNextPage, - status, - error: error as Error | null, - setInputValue, - submitSearch: (newQuery: string) => { - setQuery(newQuery); - setInputValue(newQuery); - setSeeking(searchedRecipes?.length || 0); }, - loadMore: () => setSeeking(allRecipes.length), - }; + enabled: !!mountedIndex && !!query, + staleTime: Infinity, + gcTime: Infinity, + }); + + const setInputValue = useCallback((value: string) => { + writeSession(INPUT_KEY, value); + }, []); + + const submitSearch = useCallback((newQuery: string) => { + writeSession(QUERY_KEY, newQuery); + writeSession(INPUT_KEY, newQuery); + }, []); + + const value = useMemo( + () => ({ + query, + inputValue, + searchedRecipes, + allRecipes, + indexReady, + isFetching: recipesQuery.isFetching, + status: recipesQuery.status, + error: recipesQuery.error as Error | null, + setInputValue, + submitSearch, + }), + [ + query, + inputValue, + searchedRecipes, + allRecipes, + indexReady, + recipesQuery.isFetching, + recipesQuery.status, + recipesQuery.error, + setInputValue, + submitSearch, + ], + ); return ( {children} diff --git a/websites/recipe-website/common/components/SearchForm/SearchPageWrapper.tsx b/websites/recipe-website/common/components/SearchForm/SearchPageWrapper.tsx index 840a79b4..915ac063 100644 --- a/websites/recipe-website/common/components/SearchForm/SearchPageWrapper.tsx +++ b/websites/recipe-website/common/components/SearchForm/SearchPageWrapper.tsx @@ -3,7 +3,6 @@ import { useSearchURLSync } from "./useSearchURLSync"; import { SearchInput } from "./SearchInput"; import { SearchResultsPage } from "./SearchResults"; -import { SearchPagination } from "./SearchPagination"; export function SearchPageWrapper() { useSearchURLSync(true); // Enable URL sync for page mode @@ -12,7 +11,6 @@ export function SearchPageWrapper() { <> - ); } diff --git a/websites/recipe-website/common/components/SearchForm/SearchPagination.tsx b/websites/recipe-website/common/components/SearchForm/SearchPagination.tsx deleted file mode 100644 index 7fc1f262..00000000 --- a/websites/recipe-website/common/components/SearchForm/SearchPagination.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { Button } from "@discontent/component-library/components/Button"; -import { useSearch } from "./SearchContext"; - -export function SearchPagination() { - const { hasNextPage, isFetchingNextPage, isFetching, loadMore } = useSearch(); - - return ( -
- - - {isFetching && !isFetchingNextPage ? "Fetching..." : null} - -
- ); -} diff --git a/websites/recipe-website/common/components/SearchForm/useFlexSearch.ts b/websites/recipe-website/common/components/SearchForm/useFlexSearch.ts deleted file mode 100644 index f3f3ded1..00000000 --- a/websites/recipe-website/common/components/SearchForm/useFlexSearch.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMemo } from "react"; -import { Document } from "flexsearch"; - -export function useFlexSearch( - query: string | undefined, - index: Document | undefined, - _source?: unknown, - searchOptions?: Parameters[0] | undefined, -): ReturnType { - const results = useMemo>(() => { - if (!query || !index) { - return []; - } - try { - const rawResults = index.search(query, searchOptions || {}); - return rawResults; - } catch (error) { - console.error("Search failed:", error); - return []; - } - }, [query, index, searchOptions]); - return results; -} diff --git a/websites/recipe-website/common/components/SearchForm/useSearchURLSync.ts b/websites/recipe-website/common/components/SearchForm/useSearchURLSync.ts index 8277db19..d886f988 100644 --- a/websites/recipe-website/common/components/SearchForm/useSearchURLSync.ts +++ b/websites/recipe-website/common/components/SearchForm/useSearchURLSync.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; import { useSearchParams } from "next/navigation"; import { useSearch } from "./SearchContext"; @@ -12,13 +12,17 @@ export function useSearchURLSync(enabled: boolean) { const searchParams = useSearchParams(); const { query, submitSearch, inputValue } = useSearch(); - // Initialize from URL on mount + // Initialize from URL exactly once per mount — only if sessionStorage + // didn't already seed a value. Using a ref prevents this from re-firing + // when the user clears the input (which also makes inputValue undefined). + const initializedRef = useRef(false); useEffect(() => { - if (enabled && inputValue === undefined) { - const urlQuery = searchParams.get("q"); - if (urlQuery) { - submitSearch(urlQuery); - } + if (!enabled || initializedRef.current) return; + initializedRef.current = true; + if (inputValue !== undefined) return; + const urlQuery = searchParams.get("q"); + if (urlQuery) { + submitSearch(urlQuery); } }, [enabled, searchParams, submitSearch, inputValue]); diff --git a/websites/recipe-website/common/components/SearchList/index.tsx b/websites/recipe-website/common/components/SearchList/index.tsx index 943fa253..d67e22f1 100644 --- a/websites/recipe-website/common/components/SearchList/index.tsx +++ b/websites/recipe-website/common/components/SearchList/index.tsx @@ -109,7 +109,7 @@ export default function SearchList({ {recipeResults && recipeResults.map((recipe) => { - return ( + return recipe ? (
  • - ); + ) : null; })}
    ); diff --git a/websites/recipe-website/editor/src/app/(recipes)/search/all/route.ts b/websites/recipe-website/editor/src/app/(recipes)/search/all/route.ts new file mode 100644 index 00000000..65448301 --- /dev/null +++ b/websites/recipe-website/editor/src/app/(recipes)/search/all/route.ts @@ -0,0 +1,6 @@ +import { getRecipes } from "recipe-website-common/controller/data/read"; + +export async function GET() { + const { recipes } = await getRecipes(); + return Response.json(recipes); +} diff --git a/websites/recipe-website/editor/src/app/(recipes)/search/version/route.ts b/websites/recipe-website/editor/src/app/(recipes)/search/version/route.ts new file mode 100644 index 00000000..4acb4668 --- /dev/null +++ b/websites/recipe-website/editor/src/app/(recipes)/search/version/route.ts @@ -0,0 +1,31 @@ +import { stat } from "fs/promises"; +import { resolve } from "path"; +import { getContentDirectory } from "@discontent/cms/fs/getContentDirectory"; +import { recipeContentConfig } from "recipe-website-common/controller/recipeContentConfig"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const dataFile = resolve( + getContentDirectory(), + recipeContentConfig.indexDirectory, + "data.mdb", + ); + try { + const { mtimeMs, size } = await stat(dataFile); + return Response.json( + { version: `${mtimeMs}-${size}` }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (e: Error | unknown) { + if (e instanceof Error) { + if ("code" in e && e.code === "ENOENT") { + // Index not present, return null + return Response.json( + { version: "" }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + } + } +}