diff --git a/src/__tests__/templateFilters.test.ts b/src/__tests__/templateFilters.test.ts index 90a03d4..accce12 100644 --- a/src/__tests__/templateFilters.test.ts +++ b/src/__tests__/templateFilters.test.ts @@ -12,6 +12,7 @@ import { type TemplateFilters, } from "@/lib/templateFilters"; import { TEMPLATES, templateCategories } from "@/lib/templates"; +import { readFileSync } from "node:fs"; /** * The gallery had one filter and a search box. Every listing people actually buy from @@ -120,3 +121,22 @@ describe("a filtered view is a link someone can send", () => { expect(read.filters.q.length).toBeLessThanOrEqual(100); }); }); + +describe("the gallery is a page a crawler can read", () => { + const PAGE = readFileSync("src/app/templates/page.tsx", "utf8"); + const CLIENT = readFileSync("src/app/templates/TemplatesClient.tsx", "utf8"); + + it("reads the filters on the server and hands them down", () => { + // Reading them in the browser put the whole gallery behind a Suspense boundary, and + // the served HTML then carried none of the 22 cards. + expect(PAGE).toContain("filtersFromQuery"); + expect(PAGE).toContain("initialFilters"); + expect(CLIENT).not.toContain("useSearchParams"); + expect(PAGE, "the gallery is wrapped in a boundary again").not.toMatch(/ { + expect(CLIENT).toContain("filtersToQuery"); + expect(CLIENT).toContain("router.replace"); + }); +}); diff --git a/src/app/templates/TemplatesClient.tsx b/src/app/templates/TemplatesClient.tsx index 7674211..e255e6f 100644 --- a/src/app/templates/TemplatesClient.tsx +++ b/src/app/templates/TemplatesClient.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -16,7 +16,6 @@ import { backgroundCounts, categoryCounts, filterTemplates, - filtersFromQuery, filtersToQuery, hasActiveFilters, sortTemplates, @@ -41,16 +40,24 @@ export interface TemplateCard { scrollHeight: number; } -export default function TemplatesClient({ templates, categories: allCategories }: { templates: TemplateCard[]; categories: string[] }) { +export default function TemplatesClient({ + templates, + categories: allCategories, + initialFilters, + initialSort, +}: { + templates: TemplateCard[]; + categories: string[]; + initialFilters: TemplateFilters; + initialSort: TemplateSort; +}) { const router = useRouter(); const pathname = usePathname(); - const params = useSearchParams(); - // The URL is the starting state, so a filtered gallery can be linked, bookmarked and - // sent to somebody else rather than described to them. - const fromUrl = useMemo(() => filtersFromQuery(params, { categories: allCategories }), [params, allCategories]); - const [filters, setFilters] = useState(fromUrl.filters); - const [sort, setSort] = useState(fromUrl.sort); + // The server read these out of the URL and rendered the matching cards, so a shared + // link and a crawler both get the real page. From here they are this component's. + const [filters, setFilters] = useState(initialFilters); + const [sort, setSort] = useState(initialSort); useEffect(() => { const query = filtersToQuery(filters, sort); diff --git a/src/app/templates/page.tsx b/src/app/templates/page.tsx index 99d50c8..d6b76b4 100644 --- a/src/app/templates/page.tsx +++ b/src/app/templates/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; import { pageMeta } from "@/lib/pageMeta"; import { TEMPLATES, templateCategories, templateScrollHeight, templateSectionCount } from "@/lib/templates"; import TemplatesClient, { type TemplateCard } from "./TemplatesClient"; +import { filtersFromQuery } from "@/lib/templateFilters"; export const metadata: Metadata = pageMeta({ title: "Templates", @@ -21,7 +21,7 @@ const LEAD_SLUG = "kept"; * which is the bulk of the file and none of this page. Reading it here keeps that on the * server and sends down only what a card renders. */ -export default function TemplatesPage() { +export default async function TemplatesPage({ searchParams }: { searchParams: Promise> }) { const ordered = [...TEMPLATES].sort((a, b) => Number(b.slug === LEAD_SLUG) - Number(a.slug === LEAD_SLUG)); const templates: TemplateCard[] = ordered.map((t) => ({ slug: t.slug, @@ -36,11 +36,15 @@ export default function TemplatesPage() { scrollHeight: templateScrollHeight(t), })); - // The gallery reads its filters from the URL, which is a client-side concern; the - // boundary lets the rest of the page stay static. - return ( - - - - ); + // Filters are read here rather than in the browser. Reading them client-side put the + // whole gallery behind a Suspense boundary, and the served HTML then carried none of + // the 22 cards: a crawler, and anyone arriving from search, saw an empty page. + const categories = templateCategories(); + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(await searchParams)) { + if (typeof value === "string") query.set(key, value); + } + const { filters, sort } = filtersFromQuery(query, { categories }); + + return ; }