All files / src/scripts infinite-scroll-history.ts

96.07% Statements 49/51
83.33% Branches 25/30
100% Functions 15/15
100% Lines 42/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131                        4x                 4x       77x       45x         45x 45x 1x 45x         43x               30x 30x         30x               49x 12x 12x   21x                         8x 12x 8x   12x 12x 8x     4x 4x 14x         4x 4x     1x 1x 1x 1x 1x                 4x 2x 2x   2x 2x 1x   2x     4x 1x 1x      
// Keeps an infinite-scroll listing's depth in its URL and history entry, so
// Back (or a reload) returns to the card the visitor left, not to page 1.
//
// As each page loads, the URL becomes ?page=N via replaceState (no new history
// entry; Astro's own index/scroll fields on the entry are kept). The scroll
// offset is tracked on the entry while the listing is on screen. Coming back,
// the server renders page N alone, so the listing script refetches the pages
// before it, prepends them, then scrolls to the saved offset.
//
// ?page=N stays a real, server-rendered page, so the crawlable SSR pagination
// links are unchanged.
 
const KEY = "irList";
 
export type ListEntry = {
	/** First page the visitor had in the DOM (usually 1). */
	first: number;
	/** Scroll offset when they last stopped scrolling. */
	y?: number;
};
 
let tracking = false;
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
 
function currentEntry(): ListEntry | undefined {
	return (history.state as Record<string, ListEntry> | null)?.[KEY];
}
 
function writeEntry(entry: ListEntry, url?: string): void {
	history.replaceState({ ...history.state, [KEY]: entry }, "", url);
}
 
/** Same URL, on page N (page 1 drops the param). */
export function pageHref(href: string, page: number): string {
	const url = new URL(href);
	if (page > 1) url.searchParams.set("page", String(page));
	else url.searchParams.delete("page");
	return url.pathname + url.search + url.hash;
}
 
/** Page `page` is now the last one in the DOM; `first` is the first. */
export function recordPage(first: number, page: number): void {
	writeEntry({ ...currentEntry(), first }, pageHref(location.href, page));
}
 
/**
 * The saved entry when this history entry was left deeper than the page the
 * server just rendered, i.e. the earlier pages need refetching.
 */
export function restoreFrom(currentPage: number): ListEntry | null {
	const entry = currentEntry();
	return entry && entry.first < currentPage ? entry : null;
}
 
/** Start saving the scroll offset on this entry (call once the list is final). */
export function trackScroll(): void {
	tracking = true;
}
 
/**
 * Fragment HTML → its cards. The response can lead with a scoped <style> (and
 * a Vite HMR <script> in dev); every other top-level element is a card.
 */
export function parseCards(html: string): HTMLElement[] {
	if (!html.trim()) return [];
	const parsed = new DOMParser().parseFromString(html, "text/html");
	return Array.from(parsed.body.children).filter(
		(el) =>
			el.tagName !== "STYLE" &&
			el.tagName !== "SCRIPT" &&
			el.tagName !== "TEMPLATE",
	) as HTMLElement[];
}
 
/** Fetch pages `from`..`to` and put their cards, in order, before each grid's first card. */
export async function prependPages(
	from: number,
	to: number,
	urlFor: (page: number) => string,
	grids: HTMLElement[],
): Promise<void> {
	const pages: number[] = [];
	for (let p = from; p <= to; p++) pages.push(p);
	const htmls = await Promise.all(
		pages.map(async (p) => {
			const res = await fetch(urlFor(p), { headers: { Accept: "text/html" } });
			if (!res.ok) throw new Error(`fragment fetch failed: ${res.status}`);
			return res.text();
		}),
	);
	const cards = htmls.flatMap(parseCards);
	for (const grid of grids) {
		grid.prepend(...cards.map((c) => c.cloneNode(true)));
	}
}
 
// Browser wiring: one set of listeners per page, shared by every listing.
Eif (typeof window !== "undefined") {
	window.addEventListener(
		"scroll",
		() => {
			Iif (!tracking) return;
			clearTimeout(scrollTimer);
			scrollTimer = setTimeout(() => {
				const entry = currentEntry();
				Eif (tracking && entry) writeEntry({ ...entry, y: window.scrollY });
			}, 150);
		},
		{ passive: true },
	);
	// Leaving the listing (link, Back, filter): save the offset now, then stop
	// before the router swaps pages and scrolls, or the next page's scroll
	// would overwrite it. On Back/Forward the entry has already changed, so
	// only a link or form navigation saves.
	document.addEventListener("astro:before-preparation", (e) => {
		clearTimeout(scrollTimer);
		const entry = currentEntry();
		const traverse =
			(e as Event & { navigationType?: string }).navigationType === "traverse";
		if (tracking && entry && !traverse) {
			writeEntry({ ...entry, y: window.scrollY });
		}
		tracking = false;
	});
	// Full-page navigations (data-astro-reload links, reloads) skip the router.
	window.addEventListener("pagehide", () => {
		const entry = currentEntry();
		Iif (tracking && entry) writeEntry({ ...entry, y: window.scrollY });
	});
}