web/repo: the pages after the first in a session reuse its counts, routes and newest sidebar a while
Signed-off-by: dawn <90008@klbr.net>
1234566 unmodified lines13141516171819202122231819202122232425262728293031323387 unmodified linesimport { sessionCached } from "#lib/sessionCache.ts";
// on a knot every url answers, but only the pages its renderer kept are worth loading as a// document: those paint at once, while the rest would boot the app from scratch. so a link to// a kept page reloads (and is fetched ahead on hover), any other stays a client navigationconst routesByRoot = new Map<string, Promise<Set<string> | null>>();6 unmodified lines if (!name) return owner.includes(".") || owner.startsWith("did:") ? `/${owner}` : null; return name.startsWith("_") ? null : `/${owner}/${name}`;};
// a new render rarely changes which routes are kept, and a stale list only loads a page the// other wayconst ROUTES_MAX_AGE_MS = 10 * 60_000;
const routesOf = (root: string): Promise<Set<string> | null> => { let routes = routesByRoot.get(root); if (!routes) { routes = fetch(`${root}/_latest/routes.json`) .then((response) => (response.ok ? response.json() : null)) .then((body: { routes?: string[] } | null) => body?.routes ? new Set(body.routes) : null ) .catch(() => null); routes = sessionCached(`routes:${root}`, ROUTES_MAX_AGE_MS, () => fetch(`${root}/_latest/routes.json`) .then((response) => (response.ok ? response.json() : null)) .then((body: { routes?: string[] } | null) => body?.routes ?? null) .catch(() => null) ).then((list) => (list ? new Set(list) : null)); routesByRoot.set(root, routes); } return routes;};87 unmodified lines123456789101112131415161718192021222324252627282930313233import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";import { sessionCached } from "#lib/sessionCache.ts";
describe("sessionCached", () => { beforeEach(() => { const store = new Map<string, string>(); vi.stubGlobal("sessionStorage", { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => void store.set(key, value) }); vi.useFakeTimers(); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
it("reads once while the kept answer is fresh, and again after", async () => { const load = vi.fn(async () => ({ n: load.mock.calls.length })); await expect(sessionCached("k", 1000, load)).resolves.toEqual({ n: 1 }); await expect(sessionCached("k", 1000, load)).resolves.toEqual({ n: 1 }); vi.advanceTimersByTime(1001); await expect(sessionCached("k", 1000, load)).resolves.toEqual({ n: 2 }); expect(load).toHaveBeenCalledTimes(2); });
it("doesn't keep a missing answer", async () => { const load = vi.fn(async () => null); await sessionCached("k", 1000, load); await sessionCached("k", 1000, load); expect(load).toHaveBeenCalledTimes(2); });});1234567891011121314151617181920212223242526272829303132333435// a document load starts the app again, so what one page read is kept in sessionStorage for// the next pages, for as long as it may be that staleinterface Entry<T> { at: number; value: T;}
const read = <T>(key: string): Entry<T> | null => { try { return JSON.parse(sessionStorage.getItem(key) ?? "null") as Entry<T> | null; } catch { return null; }};
const write = <T>(key: string, value: T) => { try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), value })); } catch { // a full or blocked storage only costs the next page a read }};
/** a null answer isn't kept, so the next page asks again */export const sessionCached = async <T>( key: string, maxAgeMs: number, load: () => Promise<T | null>): Promise<T | null> => { const kept = read<T>(key); if (kept && Date.now() - kept.at < maxAgeMs) return kept.value; const value = await load(); if (value !== null) write(key, value); return value;};18 unmodified lines19202122232425262751 unmodified lines7980818282838485868785868788888990919293949596979837 unmodified lines13613713813914014114214314414552 unmodified lines19819920020119420220320420520620720820920120220320421021121221321421521621721855 unmodified lines18 unmodified linesimport { prerender } from "#lib/prerender.ts";import type { BobbinContext } from "#lib/api/client.ts";import type { Fetch } from "#lib/fetch.ts";import type { RepoCounts, RepoInfo, RepoSource } from "#lib/components/repo/types.ts";import { sessionCached } from "#lib/sessionCache.ts";import type { LayoutLoad } from "./$types";
const resolveSource = async ( ctx: BobbinContext,51 unmodified lines// the root layout is csr only, the knot needs ssr on here to prerender repo pagesexport const ssr = true;
// a knot that renders pages keeps the repo's counts in one file per render, one revalidated// read instead of asking for each count// read instead of asking for each count, and the pages after it in the session reuse it a whileconst COUNTS_MAX_AGE_MS = 60_000;
const countsOf = (ctx: BobbinContext, base: string, repoDid?: string): Promise<RepoCounts> => import.meta.env.VITE_DOCUMENT_NAVIGATION ? fetch(`${base}/_latest/counts.json`) .then((response) => (response.ok ? (response.json() as Promise<RepoCounts>) : null)) .catch(() => null) .then((counts) => counts ?? loadRepoCounts(ctx, repoDid)) ? sessionCached(`counts:${base}`, COUNTS_MAX_AGE_MS, () => fetch(`${base}/_latest/counts.json`) .then((response) => response.ok ? (response.json() as Promise<RepoCounts>) : null ) .catch(() => null) ).then((counts) => counts ?? loadRepoCounts(ctx, repoDid)) : loadRepoCounts(ctx, repoDid);
const loadRepoData = ( ctx: BobbinContext,37 unmodified lines const base = `/${event.params.handle}/${event.params.repo}`; return { ...shared, src: `${base}/${knot.share(shared, `sidebar/${sidebar.ref}`)}` };};
const SIDEBAR_MAX_AGE_MS = 60_000;
const idle = () => new Promise<void>((resolve) => { if ("requestIdleCallback" in window) requestIdleCallback(() => resolve(), { timeout: 2000 });52 unmodified lines return { sidebar: stream(lastSidebar.sidebar) }; } // the page's sidebar comes from the same render as the page. once the browser is idle, the // ref's newest copy replaces it if a later render wrote one: one revalidated read rather // than listing the tree, branches, tags and languages again // than listing the tree, branches, tags and languages again, kept for the session's next // pages a while const sidebar = prerenderedSidebar(src).catch(read); const current = sidebar.then(async (then) => { if (!then?.src) return then; await idle(); const base = `/${event.params.handle}/${event.params.repo}`; const name = then.ref.split("/").map(encodeURIComponent).join("/"); // keeps the page's src, which the head already preloaded return prerenderedSidebar(`${base}/_latest/sidebar/${name}.json`).then( (latest) => ({ ...latest, src: then.src }), () => then const latest = await sessionCached(`sidebar:${base}@${name}`, SIDEBAR_MAX_AGE_MS, () => prerenderedSidebar(`${base}/_latest/sidebar/${name}.json`).catch(() => null) ); // keeps the page's src, which the head already preloaded return latest ? { ...latest, src: then.src } : then; }); lastSidebar = { key: sidebarKey, sidebar: current }; return { sidebar: stream(sidebar), currentSidebar: stream(current) };};55 unmodified lines