From 7b90691406f69bb927218bc05c972422e02ee902 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 16 Jun 2026 12:00:50 +0100 Subject: [PATCH 1/2] perf(core): eagerly prefetch site-global layout data on public pages On the anonymous public-page path (HTML navigations, request-scoped/remote backends only), warm menus, widget areas, taxonomy term lists and site settings concurrently up front via the real helpers, so the layout's per-component reads overlap into ~one wall-clock round trip and hit a warm request cache instead of serializing. Template-transparent (warms the exact keys helpers already use). Fired via after() so it runs immediately (warming the render) but the surplus warm-up is kept alive past the response by waitUntil rather than orphaning I/O on workerd. Gated to the client's preferred text/html type so feeds/JSON don't pay for chrome they never render. Helps remote backends (D1, Durable Objects) where round trips dominate; a no-op on synchronous local SQLite (gated out by the absence of a request-scoped db). --- packages/core/src/astro/middleware.ts | 22 ++++++ packages/core/src/astro/prefetch.ts | 77 +++++++++++++++++++ .../unit/menus/menu-request-cache.test.ts | 18 +++++ 3 files changed, 117 insertions(+) create mode 100644 packages/core/src/astro/prefetch.ts diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 4832db28ae..68c9ad53fe 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -60,6 +60,7 @@ import { isMissingTableError } from "../utils/db-errors.js"; import { createInitLock, type InitLock, initWithLock } from "../utils/init-lock.js"; import type { EmDashConfig } from "./integration/runtime.js"; import { wrapBodyForStreamMetrics } from "./middleware/stream-end-metrics.js"; +import { prefetchLayoutData } from "./prefetch.js"; import { createPublicPluginApiRouteHandler } from "./public-plugin-api-routes.js"; import type { EmDashHandlers } from "./types.js"; @@ -509,7 +510,28 @@ export const onRequest = defineMiddleware(async (context, next) => { const ctx = parent ? { ...parent, db: anonScoped.db } : { editMode: false, db: anonScoped.db, metrics }; + // Eagerly warm site-global layout data (menus, widget areas, + // taxonomy terms, settings) concurrently so the layout's + // per-component reads overlap into ~one wall-clock round trip and + // hit a warm cache instead of serializing. Three guards: + // - request-scoped (remote) backend only -- this branch implies it; + // pointless on synchronous local SQLite. + // - HTML navigations only -- feeds/sitemaps/JSON don't render the + // layout, so prefetching their chrome is pure waste. + // - via after(): it runs immediately (still warms the render) but + // hands the promise to waitUntil, so the surplus warm-up (chrome a + // given page doesn't render) is kept alive past the response rather + // than erroring on workerd as orphaned request I/O. + // Gate on the CLIENT'S PREFERRED type (leading media range), not a + // substring -- browser navigations lead with `text/html`, while feed + // readers lead with `application/rss+xml` etc. and only list + // `text/html;q=0.8` later, so a substring match would leak onto feeds. + const acceptsHtml = (request.headers.get("accept") ?? "") + .split(",", 1)[0]! + .trim() + .startsWith("text/html"); return runWithContext(ctx, async () => { + if (acceptsHtml) after(() => prefetchLayoutData()); const response = await runAnon(); anonScoped.commit(); return response; diff --git a/packages/core/src/astro/prefetch.ts b/packages/core/src/astro/prefetch.ts new file mode 100644 index 0000000000..19cc2c46ac --- /dev/null +++ b/packages/core/src/astro/prefetch.ts @@ -0,0 +1,77 @@ +/** + * Eager, transparent prefetch of site-global "chrome" data. + * + * On a public page render, the shared layout pulls the same site-global data on + * every request -- menus, widget areas, taxonomy term lists, site settings -- + * but each is awaited inside a separately-rendered Astro component, so they + * execute as serial DB round trips. This fires them all CONCURRENTLY at the + * very start of the request, before `next()`: + * + * - On remote backends (D1, Durable Objects) the round trips overlap instead + * of serializing, collapsing ~N sequential RTTs into ~1 wall-clock RTT. On + * a coalescing backend they additionally batch into a single round trip. + * - The results land in the per-request `requestCached` store under the exact + * keys the layout helpers use, so when the components render they hit a warm + * (in-flight or resolved) cache entry instead of issuing their own query. + * + * Nothing here changes what templates call -- it warms the real helpers, so the + * cache keys and value shapes are guaranteed identical. The caller gates this to + * the public-page path on a request-scoped (remote) backend; it is a no-op-ish + * waste on synchronous local SQLite, so don't call it there. + * + * Fire-and-forget: never awaited by middleware, never throws (a prefetch failure + * must not affect the request -- the helpers will simply run on demand). + */ + +import { getDb } from "../loader.js"; +import { getMenu } from "../menus/index.js"; +import { setRequestCacheEntry } from "../request-cache.js"; +import { getSiteSettings } from "../settings/index.js"; +import { getTaxonomyDefs, getTaxonomyTerms } from "../taxonomies/index.js"; +import { getWidgetAreas } from "../widgets/index.js"; + +/** Warm widget areas: one bulk load, primed under each per-area cache key. */ +async function prefetchWidgetAreas(): Promise { + const areas = await getWidgetAreas(); + // getWidgetArea(name) caches under `widget-area:${name}` and returns the same + // WidgetArea shape getWidgetAreas yields, so priming here makes those calls hit. + for (const area of areas) { + setRequestCacheEntry(`widget-area:${area.name}`, area); + } +} + +/** Warm every taxonomy's term list via the real helper (primes per-name keys). */ +async function prefetchTaxonomyTerms(): Promise { + const defs = await getTaxonomyDefs(); + await Promise.allSettled(defs.map((def) => getTaxonomyTerms(def.name))); +} + +/** Warm every menu via the real helper (primes `menu:${name}:${locale}`). */ +async function prefetchMenus(): Promise { + const db = await getDb(); + // The layout calls getMenu(name) with hardcoded names; we can't know them, so + // discover every menu name and warm them all (small, bounded chrome table). + const rows = await db.selectFrom("_emdash_menus").select("name").distinct().execute(); + const names = [...new Set(rows.map((r) => r.name))]; + await Promise.allSettled(names.map((name) => getMenu(name))); +} + +/** + * Concurrently warm the site-global layout data for the current request. + * Safe to call only inside the request ALS frame that owns the (remote) + * request-scoped db. Never throws. + */ +export async function prefetchLayoutData(): Promise { + try { + await Promise.allSettled([ + getSiteSettings(), + prefetchMenus(), + prefetchWidgetAreas(), + prefetchTaxonomyTerms(), + ]); + } catch (error) { + // Defensive: Promise.allSettled shouldn't reject, but never let a prefetch + // failure surface to the request. + console.error("[emdash] layout prefetch failed (non-fatal):", error); + } +} diff --git a/packages/core/tests/unit/menus/menu-request-cache.test.ts b/packages/core/tests/unit/menus/menu-request-cache.test.ts index 43735193a8..8b7bda9fc5 100644 --- a/packages/core/tests/unit/menus/menu-request-cache.test.ts +++ b/packages/core/tests/unit/menus/menu-request-cache.test.ts @@ -12,6 +12,7 @@ vi.mock("../../../src/loader.js", () => ({ getDb: vi.fn(), })); +import { prefetchLayoutData } from "../../../src/astro/prefetch.js"; import { getDb } from "../../../src/loader.js"; import { getMenu } from "../../../src/menus/index.js"; import { runWithContext } from "../../../src/request-context.js"; @@ -118,4 +119,21 @@ describe("getMenu collection-pattern request cache", () => { expect(collectionPatternQueries()).toHaveLength(1); }); + + it("prefetchLayoutData warms menus so layout getMenu calls hit the cache", async () => { + await runWithContext({ editMode: false }, async () => { + // Eager prefetch discovers every menu name and warms them up front. + await prefetchLayoutData(); + + // Anything the layout reads afterwards must be served from the + // request cache the prefetch populated -- zero further queries. + queries = []; + const primary = await getMenu("primary"); + const footer = await getMenu("footer"); + + expect(primary?.items.map((i) => i.url)).toEqual(["/blog/hello"]); + expect(footer?.items.map((i) => i.url)).toEqual(["/blog/hello"]); + expect(queries).toHaveLength(0); + }); + }); }); From d8e0572257319ee01ff7950bec2a0c134ec04a1f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 16 Jun 2026 16:28:42 +0100 Subject: [PATCH 2/2] chore: changeset for layout prefetch --- .changeset/layout-prefetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/layout-prefetch.md diff --git a/.changeset/layout-prefetch.md b/.changeset/layout-prefetch.md new file mode 100644 index 0000000000..2d5536e863 --- /dev/null +++ b/.changeset/layout-prefetch.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Speeds up public page loads on remote databases (D1, Durable Objects) by eagerly warming site-global layout data (menus, widget areas, taxonomy term lists, settings) at the start of the request, so the layout's per-component reads overlap into roughly one round trip instead of executing serially. Transparent to site code; no template changes needed.