feat(storefront): the rules for paging the catalogue (#269)

Every decision paging needs, as pure functions: which page a URL is asking for, which page is actually showable given how much there is, which slice of the items that is, and what page size to use. Pure because that is the only thing this project can unit-test — vitest runs in a node environment with no jsdom and no testing-library, so a hook or a component is only reachable through Playwright. Keeping the rules here means the rules have tests and the React wrapper stays thin enough not to need any, which is the same split filters.ts already uses for the URL.

An unrecognised page size is refused rather than clamped. A stored or hand-edited 5000 would render the entire catalogue in one page, which is the exact failure this issue exists to prevent, and clamping would quietly honour a value nobody offered. Storage access is guarded on both sides because localStorage is absent when there is no window and throws outright in some privacy modes, and neither is a reason for a customer to lose the catalogue — the worst acceptable outcome of a broken preference is the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 15:45:32 -05:00
co-authored by Claude Opus 5
parent 48b456d1b8
commit 013b6abb52
2 changed files with 257 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Paging the catalogue: every decision worth testing, as pure functions.
*
* Pure because that is the only thing this project can unit-test — vitest runs
* in a node environment with no jsdom and no testing-library, so a hook or a
* component can only be exercised through Playwright. Keeping the rules here
* means the rules have tests and the React wrapper is thin enough not to need
* any. Same split `filters.ts` already uses for the URL.
*
* Paging is client-side. `GET /api/items` still returns everything and the
* client still asks for everything; moving it server-side is a separate change
* and should be measured rather than assumed, since the filter endpoints
* already do work per request. See #269.
*/
/**
* Ten, because it is the conservative default and someone who wants a denser
* grid can say so once and be remembered.
*/
export const DEFAULT_PAGE_SIZE = 10;
/** The sizes offered in the control. A value outside this list is not honoured. */
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
export const PAGE_SIZE_STORAGE_KEY = 'catalogue:pageSize';
/**
* A page size, from whatever was supplied.
*
* Checked against the list rather than merely parsed as a number. A stored or
* hand-edited 5000 would render the entire catalogue in one page, which is the
* exact failure #269 exists to prevent, so an unrecognised size is refused
* rather than clamped — clamping would quietly honour a value nobody offered.
*/
export function readPageSize(raw: string | null): number {
const parsed = Number(raw);
const allowed = (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed);
return raw !== null && raw !== '' && allowed ? parsed : DEFAULT_PAGE_SIZE;
}
/**
* The page a URL asks for.
*
* Falls back to the first page for anything unreadable, which is the rule
* `filtersFromSearchParams` already applies to a mangled filter: a bad link
* lands somewhere sensible rather than on an error.
*/
export function pageFromSearchParams(params: URLSearchParams): number {
const parsed = Number(params.get('page'));
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1;
}
/**
* The page actually shown, given how much there is to show.
*
* A shared link to page 7 of a catalogue that has since shrunk to two pages
* shows the last page rather than an empty grid — an empty page would read as
* "this shop has nothing", which is the confusion the empty state exists to
* avoid.
*/
export function clampPage(page: number, total: number, pageSize: number): number {
const lastPage = Math.max(1, Math.ceil(total / pageSize));
return Math.min(Math.max(1, page), lastPage);
}
/** One page of items. Past the end this is empty rather than an error. */
export function pageSlice<T>(items: readonly T[], page: number, pageSize: number): T[] {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}
/**
* The remembered page size.
*
* Every access is guarded. `localStorage` is absent when there is no window at
* all and throws outright in some privacy modes, and neither is a reason for a
* customer to lose the catalogue — the worst acceptable outcome of a broken
* preference is the default.
*/
export function readStoredPageSize(storage: Pick<Storage, 'getItem'> | null): number {
if (storage === null) return DEFAULT_PAGE_SIZE;
try {
return readPageSize(storage.getItem(PAGE_SIZE_STORAGE_KEY));
} catch {
return DEFAULT_PAGE_SIZE;
}
}
/** Remembers a choice, or quietly does not. See `readStoredPageSize`. */
export function writeStoredPageSize(
storage: Pick<Storage, 'setItem'> | null,
size: number
): void {
if (storage === null) return;
try {
storage.setItem(PAGE_SIZE_STORAGE_KEY, String(size));
} catch {
// A preference that cannot be remembered is not worth an error. The
// customer's current page size still works for this visit.
}
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_PAGE_SIZE,
PAGE_SIZE_OPTIONS,
PAGE_SIZE_STORAGE_KEY,
clampPage,
pageFromSearchParams,
pageSlice,
readPageSize,
readStoredPageSize,
writeStoredPageSize
} from '../../src/pagination';
describe('the page size a customer may choose', () => {
it('defaults to 10, the conservative choice', () => {
expect(DEFAULT_PAGE_SIZE).toBe(10);
});
it('offers exactly the four sizes the issue settled on', () => {
expect([...PAGE_SIZE_OPTIONS]).toEqual([10, 20, 50, 100]);
});
it('accepts a size that is on the list', () => {
expect(readPageSize('50')).toBe(50);
});
// Anything else falls back rather than being honoured. A hand-edited value
// of 5000 would render the whole catalogue, which is the thing this issue
// exists to stop.
it('falls back to the default for a size that is not on the list', () => {
expect(readPageSize('5000')).toBe(DEFAULT_PAGE_SIZE);
expect(readPageSize('7')).toBe(DEFAULT_PAGE_SIZE);
});
it('falls back to the default for nonsense and for nothing at all', () => {
expect(readPageSize('abc')).toBe(DEFAULT_PAGE_SIZE);
expect(readPageSize('')).toBe(DEFAULT_PAGE_SIZE);
expect(readPageSize(null)).toBe(DEFAULT_PAGE_SIZE);
});
});
describe('the page number in the URL', () => {
it('is 1 when the parameter is absent', () => {
expect(pageFromSearchParams(new URLSearchParams())).toBe(1);
});
it('reads a sensible page', () => {
expect(pageFromSearchParams(new URLSearchParams('page=3'))).toBe(3);
});
// A mangled link falls back to the first page rather than to an empty view,
// the same rule filtersFromSearchParams follows for a mangled filter.
it('falls back to 1 for zero, negatives and nonsense', () => {
expect(pageFromSearchParams(new URLSearchParams('page=0'))).toBe(1);
expect(pageFromSearchParams(new URLSearchParams('page=-4'))).toBe(1);
expect(pageFromSearchParams(new URLSearchParams('page=abc'))).toBe(1);
expect(pageFromSearchParams(new URLSearchParams('page=2.5'))).toBe(1);
});
});
describe('keeping a page number inside the result set', () => {
// The case that matters: someone shares a link to page 7, the catalogue
// shrinks, and the link must show the last page rather than nothing at all.
it('clamps a page past the end back to the last page', () => {
expect(clampPage(7, 25, 10)).toBe(3);
});
it('leaves a page inside the range alone', () => {
expect(clampPage(2, 25, 10)).toBe(2);
});
// An empty result set still has a first page to show the empty state on.
it('is 1 when there is nothing to show', () => {
expect(clampPage(3, 0, 10)).toBe(1);
});
it('is 1 when everything fits on one page', () => {
expect(clampPage(4, 6, 10)).toBe(1);
});
});
describe('taking one page out of the items', () => {
const items = Array.from({ length: 25 }, (_, i) => i + 1);
it('takes the first page', () => {
expect(pageSlice(items, 1, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
it('takes a middle page', () => {
expect(pageSlice(items, 2, 10)).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
});
it('takes a short last page', () => {
expect(pageSlice(items, 3, 10)).toEqual([21, 22, 23, 24, 25]);
});
it('is empty past the end rather than throwing', () => {
expect(pageSlice(items, 9, 10)).toEqual([]);
});
});
/** A Storage that is present and works. */
function fakeStorage(initial: Record<string, string> = {}) {
const data = { ...initial };
return {
getItem: (key: string) => data[key] ?? null,
setItem: (key: string, value: string) => {
data[key] = value;
},
read: () => data
};
}
describe('remembering the choice', () => {
it('reads a stored size', () => {
expect(readStoredPageSize(fakeStorage({ [PAGE_SIZE_STORAGE_KEY]: '20' }))).toBe(20);
});
it('defaults when nothing was stored', () => {
expect(readStoredPageSize(fakeStorage())).toBe(DEFAULT_PAGE_SIZE);
});
it('writes the choice under the agreed key', () => {
const storage = fakeStorage();
writeStoredPageSize(storage, 50);
expect(storage.read()[PAGE_SIZE_STORAGE_KEY]).toBe('50');
});
// Storage is absent during server rendering and throws outright in some
// privacy modes. Neither may take the catalogue down over a preference.
it('defaults rather than throwing when storage is missing', () => {
expect(readStoredPageSize(null)).toBe(DEFAULT_PAGE_SIZE);
});
it('defaults rather than throwing when storage refuses to be read', () => {
const hostile = {
getItem: () => {
throw new Error('access denied');
}
};
expect(readStoredPageSize(hostile)).toBe(DEFAULT_PAGE_SIZE);
});
it('gives up quietly when storage refuses to be written', () => {
const hostile = {
setItem: () => {
throw new Error('quota exceeded');
}
};
expect(() => writeStoredPageSize(hostile, 20)).not.toThrow();
});
it('does nothing when there is no storage to write to', () => {
expect(() => writeStoredPageSize(null, 20)).not.toThrow();
});
});