Cache stubs, prefetch next page, parallelize author profile fetch (AI)

resolveStub() now caches every resolved stub (not just ones that get
hydrated), so a repeat visit skips the listing fetch for articles
nothing has ever rendered too, not only already-viewed ones. Once a
page's cards render, the next page's content hydrates in the
background so "next" is usually an instant cache hit. loadAuthorView
fetches the profile and article list in parallel instead of
sequentially.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
This commit is contained in:
biosiasandClaude Sonnet 5 committed 2026-09-11 13:30:27 +02:00
1 parent 81fb2a2414
commit 202f88b206
3 files changed
+38 -13

No files matched your search

+11 -6
View File
@@ -360,12 +360,14 @@ for articles about to be rendered: the current page's cards (`hydrateArticles()`
every result of a search. A large site's list is therefore fast to show regardless of how many
articles it has — only ~20 articles' worth of content is ever fetched per page view.
- **Cache** — once an article is hydrated, the result is cached per post directory in IndexedDB
(`cmjs/js/cache.js`), keyed by directory path and invalidated by comparing the live `mtime`
against the cached one — a stub whose directory `mtime` hasn't changed reuses the cached,
already-hydrated result directly, skipping both the listing and content fetch. If IndexedDB is
unavailable (private browsing, disabled storage), discovery transparently falls back to
fetching everything live on every visit.
- **Cache** — every resolved stub is cached per post directory in IndexedDB (`cmjs/js/cache.js`),
keyed by directory path and invalidated by comparing the live `mtime` against the cached one —
as soon as that comparison matches, indexing reuses the cached entry directly and skips the
listing fetch too, whether or not the article has ever actually been hydrated. Hydration
re-caches the same entry with the fetched content added, so once *any* visit renders an
article, every later visit skips its content fetch as well, until its directory's `mtime`
changes. If IndexedDB is unavailable (private browsing, disabled storage), discovery
transparently falls back to fetching everything live on every visit.
- **Cache schema version** — the cached records store the full derived article shape, not raw
content, so a code change to how articles are derived (in `cmjs/js/discovery.js`) can make old
cached entries stale in *structure*, not just content — and a directory's `mtime` won't catch
@@ -382,6 +384,9 @@ articles it has — only ~20 articles' worth of content is ever fetched per page
also visits directories with the most recent `mtime` first (a best-effort heuristic, not the
final display order — that's always the authoritative `.date` marker), so the first results
tend to already be close to correct.
- **Prefetching** — once the current page has fully rendered, the next page's articles are
hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache
hit rather than a fresh round-trip.
- **Crawl health** — if every author's directory listing fails outright, or very few of the
known post directories loaded, a small warning banner is shown above the article list instead
of silently rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in
+9 -5
View File
@@ -205,13 +205,17 @@ async function buildStub(dir) {
}
}
// Cache-aware: a directory whose mtime matches its cached (already-hydrated)
// entry is reused directly, skipping the listing fetch entirely; otherwise
// builds a fresh, unhydrated stub from the listing alone.
// Cache-aware: a directory whose mtime matches its cached entry (stub *or*
// already-hydrated, either is fine here) is reused directly, skipping the
// listing fetch entirely; otherwise builds a fresh stub from the listing
// and caches that too, so a repeat visit skips the listing fetch even for
// an article nothing has hydrated yet — not just already-viewed ones.
async function resolveStub(dir) {
const cached = await getCacheEntry(dir.root);
if (cached && dir.mtime != null && cached.mtime === dir.mtime) return cached.article;
return buildStub(dir);
const stub = await buildStub(dir);
if (stub) await setCacheEntry(dir.root, { mtime: dir.mtime, article: stub });
return stub;
}
// Fields only derivable from an article's actual markdown content (not its
@@ -316,7 +320,7 @@ async function runCrawl() {
const crawlOrder = [...slugDirs].sort((a, b) => mtimeSortValue(b) - mtimeSortValue(a));
// Wave 3 — per slug directory, cache-aware and concurrency-limited: a
// directory whose mtime matches its cached (already-hydrated) entry
// directory whose mtime matches its cached entry (stub or hydrated)
// skips the listing fetch entirely; otherwise just its listing (for
// .tag/.date/draft markers) — not its markdown content, which is
// deferred to hydrateArticle() until something actually renders it.
+18 -2
View File
@@ -276,6 +276,15 @@ export async function loadArticleList(filter = null, page = 1) {
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
// page so clicking "next" is instant. Fire-and-forget — it touches
// no DOM, just fetches and caches, so it's safe to keep running
// even if the user has since navigated elsewhere.
if (done && clampedPage < pages) {
hydrateArticles(articles.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE));
}
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
@@ -321,8 +330,15 @@ export async function loadAuthorView(author, page = 1) {
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput();
try {
const profile = await fetchAuthorProfile(author);
await loadArticleList({ type: 'author', value: author }, page);
// Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with.
const [profile] = await Promise.all([
fetchAuthorProfile(author),
loadArticleList({ type: 'author', value: author }, page),
]);
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild);
} catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; }
}