perf(zxdb): server-render index pages with ISR and initial data
Why - Reduce time-to-first-content on ZXDB index pages by eliminating the initial client-side fetch and enabling incremental static regeneration. What - Main Explorer (/zxdb): - Server-renders first page of results and lookup lists (genres, languages, machinetypes) and passes them as initial props. - Keeps client interactivity for subsequent searches/filters. - Labels index (/zxdb/labels): - Server-renders first page of empty search and passes as initial props to skip the first fetch. - Category lists: - Genres (/zxdb/genres), Languages (/zxdb/languages), Machine Types (/zxdb/machinetypes) now server-render their lists and export revalidate=3600. - Refactored list components to accept server-provided items; removed on-mount fetching. - Links & prefetch: - Replaced remaining anchors with Next Link to enable prefetch where applicable. Tech details - Added revalidate=3600 to the index pages for ISR. - Updated ZxdbExplorer to accept initial results and initial filter lists; skips first client fetch when initial props are present. - Updated LabelsSearch to accept initial payload and skip first fetch in default state. - Updated GenreList, LanguageList, MachineTypeList to be presentational components receiving items from server pages. Notes - Low-churn list APIs already emit Cache-Control for CDN; list pages now render instantly from server. - Further polish (breadcrumbs, facet counts UI) can build on this foundation without reintroducing initial network waits. Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
@@ -1,16 +1,28 @@
|
|||||||
chore: commit pending ZXDB explorer changes prior to index perf work
|
perf(zxdb): server-render index pages with ISR and initial data
|
||||||
|
|
||||||
Context
|
Why
|
||||||
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.
|
- Reduce time-to-first-content on ZXDB index pages by eliminating the initial client-side fetch and enabling incremental static regeneration.
|
||||||
|
|
||||||
Includes
|
What
|
||||||
- Server-rendered entry detail page with ISR and parallelized DB queries.
|
- Main Explorer (/zxdb):
|
||||||
- Node runtime for ZXDB API routes and params validation updates for Next 15.
|
- Server-renders first page of results and lookup lists (genres, languages, machinetypes) and passes them as initial props.
|
||||||
- ZXDB repository extensions (facets, label queries, category queries).
|
- Keeps client interactivity for subsequent searches/filters.
|
||||||
- Cross-linking and Link-based prefetch across ZXDB UI.
|
- Labels index (/zxdb/labels):
|
||||||
- Cache headers on low-churn list APIs.
|
- Server-renders first page of empty search and passes as initial props to skip the first fetch.
|
||||||
|
- Category lists:
|
||||||
|
- Genres (/zxdb/genres), Languages (/zxdb/languages), Machine Types (/zxdb/machinetypes) now server-render their lists and export revalidate=3600.
|
||||||
|
- Refactored list components to accept server-provided items; removed on-mount fetching.
|
||||||
|
- Links & prefetch:
|
||||||
|
- Replaced remaining anchors with Next Link to enable prefetch where applicable.
|
||||||
|
|
||||||
|
Tech details
|
||||||
|
- Added revalidate=3600 to the index pages for ISR.
|
||||||
|
- Updated ZxdbExplorer to accept initial results and initial filter lists; skips first client fetch when initial props are present.
|
||||||
|
- Updated LabelsSearch to accept initial payload and skip first fetch in default state.
|
||||||
|
- Updated GenreList, LanguageList, MachineTypeList to be presentational components receiving items from server pages.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.
|
- Low-churn list APIs already emit Cache-Control for CDN; list pages now render instantly from server.
|
||||||
|
- Further polish (breadcrumbs, facet counts UI) can build on this foundation without reintroducing initial network waits.
|
||||||
|
|
||||||
Signed-off-by: Junie@lucy.xalior.com
|
Signed-off-by: Junie@lucy.xalior.com
|
||||||
@@ -18,14 +18,24 @@ type Paged<T> = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ZxdbExplorer() {
|
export default function ZxdbExplorer({
|
||||||
|
initial,
|
||||||
|
initialGenres,
|
||||||
|
initialLanguages,
|
||||||
|
initialMachines,
|
||||||
|
}: {
|
||||||
|
initial?: Paged<Item>;
|
||||||
|
initialGenres?: { id: number; name: string }[];
|
||||||
|
initialLanguages?: { id: string; name: string }[];
|
||||||
|
initialMachines?: { id: number; name: string }[];
|
||||||
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
|
||||||
const [genres, setGenres] = useState<{ id: number; name: string }[]>([]);
|
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
|
||||||
const [languages, setLanguages] = useState<{ id: string; name: string }[]>([]);
|
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
|
||||||
const [machines, setMachines] = useState<{ id: number; name: string }[]>([]);
|
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
|
||||||
const [genreId, setGenreId] = useState<number | "">("");
|
const [genreId, setGenreId] = useState<number | "">("");
|
||||||
const [languageId, setLanguageId] = useState<string | "">("");
|
const [languageId, setLanguageId] = useState<string | "">("");
|
||||||
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
||||||
@@ -59,18 +69,23 @@ export default function ZxdbExplorer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Avoid immediate client fetch on first paint if server provided initial data
|
||||||
|
if (initial && page === 1 && q === "" && genreId === "" && languageId === "" && machinetypeId === "" && sort === "title") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchData(q, page);
|
fetchData(q, page);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page, genreId, languageId, machinetypeId, sort]);
|
}, [page, genreId, languageId, machinetypeId, sort]);
|
||||||
|
|
||||||
// Load filter lists once
|
// Load filter lists on mount only if not provided by server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialGenres && initialLanguages && initialMachines) return;
|
||||||
async function loadLists() {
|
async function loadLists() {
|
||||||
try {
|
try {
|
||||||
const [g, l, m] = await Promise.all([
|
const [g, l, m] = await Promise.all([
|
||||||
fetch("/api/zxdb/genres", { cache: "no-store" }).then((r) => r.json()),
|
fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
fetch("/api/zxdb/languages", { cache: "no-store" }).then((r) => r.json()),
|
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
fetch("/api/zxdb/machinetypes", { cache: "no-store" }).then((r) => r.json()),
|
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
]);
|
]);
|
||||||
setGenres(g.items ?? []);
|
setGenres(g.items ?? []);
|
||||||
setLanguages(l.items ?? []);
|
setLanguages(l.items ?? []);
|
||||||
@@ -78,7 +93,7 @@ export default function ZxdbExplorer() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
loadLists();
|
loadLists();
|
||||||
}, []);
|
}, [initialGenres, initialLanguages, initialMachines]);
|
||||||
|
|
||||||
function onSubmit(e: React.FormEvent) {
|
function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type Genre = { id: number; name: string };
|
type Genre = { id: number; name: string };
|
||||||
|
|
||||||
export default function GenreList() {
|
export default function GenreList({ items }: { items: Genre[] }) {
|
||||||
const [items, setItems] = useState<Genre[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/zxdb/genres", { cache: "no-store" });
|
|
||||||
const json = await res.json();
|
|
||||||
setItems(json.items ?? []);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading…</div>;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Genres</h1>
|
<h1>Genres</h1>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import GenreList from "./GenreList";
|
import GenreList from "./GenreList";
|
||||||
|
import { listGenres } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Genres" };
|
export const metadata = { title: "ZXDB Genres" };
|
||||||
|
|
||||||
export default function Page() {
|
export const revalidate = 3600;
|
||||||
return <GenreList />;
|
|
||||||
|
export default async function Page() {
|
||||||
|
const items = await listGenres();
|
||||||
|
return <GenreList items={items as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
export default function LabelsSearch() {
|
export default function LabelsSearch({ initial }: { initial?: Paged<Label> }) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [data, setData] = useState<Paged<Label> | null>(null);
|
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const firstLoadSkipped = useRef(false);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ export default function LabelsSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// If server provided initial data for first page of empty search, skip first fetch
|
||||||
|
if (!firstLoadSkipped.current && initial && !q && page === 1) {
|
||||||
|
firstLoadSkipped.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
load();
|
load();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import LabelsSearch from "./LabelsSearch";
|
import LabelsSearch from "./LabelsSearch";
|
||||||
|
import { searchLabels } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Labels" };
|
export const metadata = { title: "ZXDB Labels" };
|
||||||
|
|
||||||
export default function Page() {
|
export const revalidate = 3600;
|
||||||
return <LabelsSearch />;
|
|
||||||
|
export default async function Page() {
|
||||||
|
// Server-render first page of empty search for instant content
|
||||||
|
const initial = await searchLabels({ page: 1, pageSize: 20 });
|
||||||
|
return <LabelsSearch initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type Language = { id: string; name: string };
|
type Language = { id: string; name: string };
|
||||||
|
|
||||||
export default function LanguageList() {
|
export default function LanguageList({ items }: { items: Language[] }) {
|
||||||
const [items, setItems] = useState<Language[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/zxdb/languages", { cache: "no-store" });
|
|
||||||
const json = await res.json();
|
|
||||||
setItems(json.items ?? []);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading…</div>;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Languages</h1>
|
<h1>Languages</h1>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import LanguageList from "./LanguageList";
|
import LanguageList from "./LanguageList";
|
||||||
|
import { listLanguages } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Languages" };
|
export const metadata = { title: "ZXDB Languages" };
|
||||||
|
|
||||||
export default function Page() {
|
export const revalidate = 3600;
|
||||||
return <LanguageList />;
|
|
||||||
|
export default async function Page() {
|
||||||
|
const items = await listLanguages();
|
||||||
|
return <LanguageList items={items as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import Link from "next/link";
|
||||||
|
|
||||||
type MT = { id: number; name: string };
|
type MT = { id: number; name: string };
|
||||||
|
|
||||||
export default function MachineTypeList() {
|
export default function MachineTypeList({ items }: { items: MT[] }) {
|
||||||
const [items, setItems] = useState<MT[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/zxdb/machinetypes", { cache: "no-store" });
|
|
||||||
const json = await res.json();
|
|
||||||
setItems(json.items ?? []);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading…</div>;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Machine Types</h1>
|
<h1>Machine Types</h1>
|
||||||
<ul className="list-group">
|
<ul className="list-group">
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<li key={m.id} className="list-group-item d-flex justify-content-between align-items-center">
|
<li key={m.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<a href={`/zxdb/machinetypes/${m.id}`}>{m.name}</a>
|
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||||
<span className="badge text-bg-light">#{m.id}</span>
|
<span className="badge text-bg-light">#{m.id}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
11
src/app/zxdb/machinetypes/page.tsx
Normal file
11
src/app/zxdb/machinetypes/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import MachineTypeList from "./MachineTypeList";
|
||||||
|
import { listMachinetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Machine Types" };
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const items = await listMachinetypes();
|
||||||
|
return <MachineTypeList items={items as any} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ZxdbExplorer from "./ZxdbExplorer";
|
import ZxdbExplorer from "./ZxdbExplorer";
|
||||||
import { searchEntries } from "@/server/repo/zxdb";
|
import { searchEntries, listGenres, listLanguages, listMachinetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Explorer",
|
title: "ZXDB Explorer",
|
||||||
@@ -9,6 +9,18 @@ export const revalidate = 3600;
|
|||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
// Server-render initial page (no query) to avoid first client fetch
|
// Server-render initial page (no query) to avoid first client fetch
|
||||||
const initial = await searchEntries({ page: 1, pageSize: 20, sort: "id_desc" });
|
const [initial, genres, langs, machines] = await Promise.all([
|
||||||
return <ZxdbExplorer initial={initial as any} />;
|
searchEntries({ page: 1, pageSize: 20, sort: "id_desc" }),
|
||||||
|
listGenres(),
|
||||||
|
listLanguages(),
|
||||||
|
listMachinetypes(),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<ZxdbExplorer
|
||||||
|
initial={initial as any}
|
||||||
|
initialGenres={genres as any}
|
||||||
|
initialLanguages={langs as any}
|
||||||
|
initialMachines={machines as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user