Skip to content
Merged
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
20 changes: 20 additions & 0 deletions src/__tests__/templateFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(/<Suspense/);
});

it("still lets the browser own the filters once the page is interactive", () => {
expect(CLIENT).toContain("filtersToQuery");
expect(CLIENT).toContain("router.replace");
});
});
25 changes: 16 additions & 9 deletions src/app/templates/TemplatesClient.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,7 +16,6 @@ import {
backgroundCounts,
categoryCounts,
filterTemplates,
filtersFromQuery,
filtersToQuery,
hasActiveFilters,
sortTemplates,
Expand All @@ -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<TemplateFilters>(fromUrl.filters);
const [sort, setSort] = useState<TemplateSort>(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<TemplateFilters>(initialFilters);
const [sort, setSort] = useState<TemplateSort>(initialSort);

useEffect(() => {
const query = filtersToQuery(filters, sort);
Expand Down
22 changes: 13 additions & 9 deletions src/app/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<Record<string, string | string[] | undefined>> }) {
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,
Expand All @@ -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 (
<Suspense fallback={null}>
<TemplatesClient templates={templates} categories={templateCategories()} />
</Suspense>
);
// 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 <TemplatesClient templates={templates} categories={categories} initialFilters={filters} initialSort={sort} />;
}
Loading