diff --git a/README.md b/README.md index a664428..cbf9675 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 86e7e1b..5450c64 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -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. diff --git a/cmjs/js/views.js b/cmjs/js/views.js index 516fff8..60bb6ca 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -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 = `

${t('loadingGeneric')}

`; 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 = `

${t('articlesLoadError', { message: error.message })}

`; } }