perf: cache search and news archive, drop motion/react from public pages
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 26s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Failing after 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 26s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Failing after 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four remaining LOW items. Search and news archive caching - A leading-wildcard LIKE cannot use an index, so every /search section cost a COUNT(*) scan plus an ordered page fetch, and /news did the same for its archive. Both now cache: search per section for 30s, the archive for 60s under the existing news revision so publishing an article drops it at once. - Sections are cached independently, so one slow query cannot hold up the rest and a failure is not cached as a result. - The cached value is passed through cacheSafe() so the Redis path and the in-process path return the same types; without it a cache hit would hand the events grid a string where a miss hands it a Date, and it calls toISOString() on that field. Dates are revived on the way out so the public signatures of loadNewsArchive and loadPublicSearch are unchanged. - Archive entries are keyed on the REQUESTED page rather than the clamped one, so two requests that clamp onto the same page cannot alias each other. motion/react out of the public bundle - Converted the six public-facing users: the radio player, the typewriter text (a motion.span with no animation props at all), the photo lightbox, the animated counter, the footer CMS-info popup and the scroll reveal. That was the actual entry points — the counter and the popup reach the public home page and footer through static imports, so removing only the three originally named would have left the library in the bundle anyway. - Each animation moved to a CSS class, and the two that animate on exit now hold the element for the length of the fade, which is what AnimatePresence used to do. - motion/react now only ships with /admin and the two already-lazy nav panels. - Two safety fixes came out of this: the scroll reveal starts at opacity 0, so it is forced visible under prefers-reduced-motion and via a <noscript> rule in the root layout; and it now emits the .motion-reveal class, which the theme panel's "Scroll Reveal" toggle selects and which previously matched nothing. - The CMS-info backdrop became a real button in a pointer-transparent layer instead of a handler on a static element, so click-outside-to-dismiss is reachable by keyboard. Fewer duplicate router refreshes - Next.js re-renders the current route as part of a server action's own response when that action revalidates, and applies it with a seeded navigation; the router only skips its own update when the action did NOT revalidate. So the refresh after such an action fetched the same tree twice. - useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in per call rather than derived from an action name, since a rename would silently change behaviour. Applied to the two user-facing call sites whose actions were verified to revalidate their own route. Touch targets - .btn was the one shared control at 40px; it and the lightbox and CMS-info close buttons are now 44px, as is the password toggle (the auth input already reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which only asks for 24px; enlarging those inside inputs and overlays was left alone because it risks visual breakage that cannot be checked from here.
This commit is contained in:
1 parent
179484642f
commit
759ae91745
16 files changed
+794
-262
No files matched your search
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const state = vi.hoisted(() => ({
|
||||
total: 55,
|
||||
fail: false,
|
||||
rows: [] as unknown[],
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
@@ -14,7 +15,8 @@ vi.mock("@/lib/db", async () => {
|
||||
db: drizzle(async (sql, params) => {
|
||||
if (state.fail) throw new Error("unavailable");
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
|
||||
if (sql.includes("count(*)")) return { rows: [[state.total]] };
|
||||
return { rows: state.rows };
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -25,6 +27,7 @@ describe("public news archive", () => {
|
||||
beforeEach(() => {
|
||||
state.total = 55;
|
||||
state.fail = false;
|
||||
state.rows = [];
|
||||
state.queries = [];
|
||||
});
|
||||
it("filters unpublished and future articles before counting and paginating", async () => {
|
||||
@@ -61,6 +64,46 @@ describe("public news archive", () => {
|
||||
});
|
||||
it("propagates failures instead of presenting them as an empty archive", async () => {
|
||||
state.fail = true;
|
||||
await expect(loadNewsArchive()).rejects.toThrow();
|
||||
// Its own search term, so the entry is a cache miss and the failure
|
||||
// actually reaches the database.
|
||||
await expect(loadNewsArchive({ search: "boom" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("serves a repeated query from cache without querying again", async () => {
|
||||
const first = await loadNewsArchive({ search: "repeat", page: 1 });
|
||||
expect(state.queries.length).toBeGreaterThan(0);
|
||||
const after = state.queries.length;
|
||||
const second = await loadNewsArchive({ search: "repeat", page: 1 });
|
||||
expect(second).toEqual(first);
|
||||
expect(state.queries.length).toBe(after);
|
||||
});
|
||||
|
||||
it("keeps cache entries separate per search, page and order", async () => {
|
||||
await loadNewsArchive({ search: "distinct", page: 1 });
|
||||
const after = state.queries.length;
|
||||
await loadNewsArchive({ search: "distinct", page: 2 });
|
||||
await loadNewsArchive({ search: "distinct", page: 1, order: "oldest" });
|
||||
await loadNewsArchive({ search: "different", page: 1 });
|
||||
expect(state.queries.length).toBeGreaterThan(after);
|
||||
});
|
||||
|
||||
it("returns createdAt as a Date on a cache hit", async () => {
|
||||
const rows = [
|
||||
{
|
||||
slug: "a",
|
||||
title: "A",
|
||||
shortStory: "s",
|
||||
image: null,
|
||||
createdAt: new Date("2024-01-02T03:04:05.000Z"),
|
||||
},
|
||||
];
|
||||
state.rows = rows;
|
||||
const first = await loadNewsArchive({ search: "dates" });
|
||||
expect(first.rows[0]?.createdAt).toBeInstanceOf(Date);
|
||||
const second = await loadNewsArchive({ search: "dates" });
|
||||
expect(second.rows[0]?.createdAt).toBeInstanceOf(Date);
|
||||
expect(second.rows[0]?.createdAt.getTime()).toBe(
|
||||
first.rows[0]?.createdAt.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,20 @@
|
||||
import "server-only";
|
||||
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { cacheSafe } from "@/lib/redis-cache";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
export async function loadNewsArchive(
|
||||
/**
|
||||
* Fresh for a minute, then servable stale for another minute. Both the landing
|
||||
* page and /search hit this, and the COUNT(*) over a leading-wildcard LIKE is a
|
||||
* full scan of website_articles — the reason the archive felt slow once the
|
||||
* table grew. Publishing an article rotates the news revision and drops these
|
||||
* entries immediately, so the window only applies when pub/sub cannot be
|
||||
* delivered.
|
||||
*/
|
||||
const ARCHIVE_TTL_MS = 60_000;
|
||||
|
||||
async function queryNewsArchive(
|
||||
options: { search?: string; page?: number; order?: string } = {},
|
||||
) {
|
||||
const search = (typeof options.search === "string" ? options.search : "")
|
||||
@@ -53,5 +65,47 @@ export async function loadNewsArchive(
|
||||
.orderBy(sort(publishedAt), sort(WebsiteArticles.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage);
|
||||
return { rows, total, page, perPage, lastPage, search, order };
|
||||
// cacheSafe() flattens the Date into the same ISO string on both cache
|
||||
// paths. Without it a Redis hit would hand callers a string where a miss
|
||||
// hands them a Date, and the archive grid formats this field directly.
|
||||
return cacheSafe({ rows, total, page, perPage, lastPage, search, order });
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed on the REQUESTED page rather than the clamped one, so two requests that
|
||||
* clamp onto the same page cannot alias each other's entry.
|
||||
*/
|
||||
function archiveKey(options: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
order?: string;
|
||||
}): string {
|
||||
const search = (typeof options.search === "string" ? options.search : "")
|
||||
.trim()
|
||||
.slice(0, 191);
|
||||
const order = options.order === "oldest" ? "oldest" : "newest";
|
||||
const page = Number.isFinite(options.page)
|
||||
? Math.trunc(options.page ?? 1)
|
||||
: 1;
|
||||
return `news:archive:${order}:${page}:${search}`;
|
||||
}
|
||||
|
||||
export async function loadNewsArchive(
|
||||
options: { search?: string; page?: number; order?: string } = {},
|
||||
) {
|
||||
const key = archiveKey(options);
|
||||
// cacheNews() namespaces by the news revision and owns the stale window.
|
||||
const result = await cacheNews(key, ARCHIVE_TTL_MS, () =>
|
||||
queryNewsArchive(options),
|
||||
);
|
||||
|
||||
// Revive the one field callers treat as a Date. Kept outside the cache so
|
||||
// the cached value stays plain JSON.
|
||||
return {
|
||||
...result,
|
||||
rows: result.rows.map((row) => ({
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
import { cached } from "@/lib/cache";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
import "server-only";
|
||||
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
|
||||
import { db, Rooms, User, WebsiteEvent } from "@/lib/db";
|
||||
import { cacheSafe } from "@/lib/redis-cache";
|
||||
import { loadNewsArchive } from "@/lib/services/news-archive";
|
||||
|
||||
/**
|
||||
* A leading-wildcard LIKE cannot use an index, so every search section costs a
|
||||
* COUNT(*) scan plus an ordered page fetch. Repeated queries are common enough
|
||||
* (nav links, back-navigation, the same popular terms from many visitors) to be
|
||||
* worth a short cache. The window is deliberately short: this is a live
|
||||
* directory, and a user who registers or a room that opens should show up
|
||||
* without waiting.
|
||||
*/
|
||||
const SEARCH_TTL_MS = 30_000;
|
||||
|
||||
function pageNumber(value: number | undefined, total: number) {
|
||||
return Math.min(
|
||||
Math.max(1, Math.ceil(total / 12)),
|
||||
Number.isFinite(value) ? Math.max(1, Math.trunc(value ?? 1)) : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections are cached independently so one slow query cannot hold up the rest,
|
||||
* and so a failure stays a failure instead of being cached as a result.
|
||||
*/
|
||||
function searchKey(section: string, query: string, page: number | undefined) {
|
||||
const requested = Number.isFinite(page) ? Math.trunc(page ?? 1) : 1;
|
||||
return `search:${section}:${requested}:${query}`;
|
||||
}
|
||||
|
||||
export async function loadPublicSearch(options: {
|
||||
query: string;
|
||||
usersPage?: number;
|
||||
@@ -34,82 +56,104 @@ export async function loadPublicSearch(options: {
|
||||
),
|
||||
);
|
||||
const [users, rooms, news, events] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(User)
|
||||
.where(userWhere);
|
||||
const page = pageNumber(options.usersPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(userWhere)
|
||||
.orderBy(asc(User.username), asc(User.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
return {
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
};
|
||||
})(),
|
||||
(async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(Rooms)
|
||||
.where(roomWhere);
|
||||
const page = pageNumber(options.roomsPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
name: Rooms.name,
|
||||
ownerName: Rooms.ownerName,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(roomWhere)
|
||||
.orderBy(asc(Rooms.name), asc(Rooms.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
return {
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
};
|
||||
})(),
|
||||
cached(
|
||||
searchKey("users", query, options.usersPage),
|
||||
SEARCH_TTL_MS,
|
||||
async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(User)
|
||||
.where(userWhere);
|
||||
const page = pageNumber(options.usersPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(userWhere)
|
||||
.orderBy(asc(User.username), asc(User.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
return cacheSafe({
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
});
|
||||
},
|
||||
),
|
||||
cached(
|
||||
searchKey("rooms", query, options.roomsPage),
|
||||
SEARCH_TTL_MS,
|
||||
async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(Rooms)
|
||||
.where(roomWhere);
|
||||
const page = pageNumber(options.roomsPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
name: Rooms.name,
|
||||
ownerName: Rooms.ownerName,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(roomWhere)
|
||||
.orderBy(asc(Rooms.name), asc(Rooms.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
return cacheSafe({
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
});
|
||||
},
|
||||
),
|
||||
loadNewsArchive({ search: query, page: options.newsPage }),
|
||||
(async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(WebsiteEvent)
|
||||
.where(eventWhere);
|
||||
const page = pageNumber(options.eventsPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eventWhere)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
return {
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
};
|
||||
})(),
|
||||
cached(
|
||||
searchKey("events", query, options.eventsPage),
|
||||
SEARCH_TTL_MS,
|
||||
async () => {
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: count() })
|
||||
.from(WebsiteEvent)
|
||||
.where(eventWhere);
|
||||
const page = pageNumber(options.eventsPage, Number(total));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eventWhere)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(12)
|
||||
.offset((page - 1) * 12);
|
||||
// cacheSafe() turns startsAt into an ISO string for both cache
|
||||
// paths; the page formats it with toISOString(), so revive it
|
||||
// here to keep that call valid on a cache hit too.
|
||||
const cachedResult = cacheSafe({
|
||||
rows,
|
||||
total: Number(total),
|
||||
page,
|
||||
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
|
||||
});
|
||||
return {
|
||||
...cachedResult,
|
||||
rows: cachedResult.rows.map((row) => ({
|
||||
...row,
|
||||
startsAt: new Date(row.startsAt),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
]);
|
||||
for (const [section, result] of Object.entries({
|
||||
users,
|
||||
|
||||
Reference in new issue
Block a user