Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-404-rewrite-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes duplicate layout queries when anonymous public pages call `Astro.rewrite()`, such as rewriting an unknown slug to `/404`. The rewritten page reuses the original request's cached reads and prefetched layout data. Previews, edit mode, signed-in requests, and rewrites that change locale continue to use fresh request contexts.
59 changes: 58 additions & 1 deletion packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,44 @@ function keepUnsharedResponsesOutOfRouteCache(context: APIContext, response: Res
}
}

const ANONYMOUS_RENDER = Symbol.for("emdash:anonymous-render");

type RenderLocals = App.Locals & {
[ANONYMOUS_RENDER]?: {
method: string;
locale: string | undefined;
db: unknown;
metrics: RequestMetrics | undefined;
};
};

function canReuseAnonymousRender(context: APIContext): boolean {
const original = (context.locals as RenderLocals)[ANONYMOUS_RENDER];
const parent = getRequestContext();
if (!original || !parent) return false;
if (context.request.method !== original.method) return false;
if (context.currentLocale !== original.locale) return false;
if (context.locals.__playgroundDb || context.locals.user || parent.editMode || parent.preview)
return false;
if (parent.db !== original.db || parent.metrics !== original.metrics) return false;
if (context.request.headers.has("authorization")) return false;
if (context.cookies.get(virtualConfig?.sessionCookieName ?? "astro-session")) return false;
if (context.cookies.get("emdash-edit-mode")?.value === "true") return false;
if (context.url.searchParams.has("_preview")) return false;
if (context.url.pathname.startsWith("/_emdash") || isImageEndpointRequest(context)) return false;
const base = import.meta.env.BASE_URL.replace(TRAILING_SLASH_RE, "");
const path =
base && context.url.pathname.startsWith(`${base}/`)
? context.url.pathname.slice(base.length)
: context.url.pathname;
if (PUBLIC_RUNTIME_ROUTES.has(path) || isCollectionSitemapPath(path)) return false;
// A newly cacheable target must not reuse data loaded from an unverified object-cache snapshot.
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled && !parent.routeCacheFill) {
return false;
}
return true;
}

export const onRequest = defineMiddleware(async (context, next) => {
const { request, locals, cookies } = context;
const url = context.url;
Expand All @@ -776,6 +814,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
}

// Astro re-enters middleware with the same locals while the original render is
// still active. Its outer scope owns prefetch, metrics, and connection cleanup.
if (canReuseAnonymousRender(context)) return next();

const queryRecorder = isInstrumentationEnabled()
? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
: undefined;
Expand Down Expand Up @@ -980,7 +1022,22 @@ export const onRequest = defineMiddleware(async (context, next) => {
});
const runAnon = async () => {
const t0 = performance.now();
const response = await next();
const renderLocals = locals as RenderLocals;
const previous = renderLocals[ANONYMOUS_RENDER];
const scope = getRequestContext();
renderLocals[ANONYMOUS_RENDER] = {
method: request.method,
locale: context.currentLocale,
db: scope?.db,
metrics: scope?.metrics,
};
let response: Response;
try {
response = await next();
} finally {
if (previous) renderLocals[ANONYMOUS_RENDER] = previous;
else delete renderLocals[ANONYMOUS_RENDER];
}
timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
pushMetricsTimings(timings, metrics);
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/astro/middleware/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (!routeCache?.enabled) return run();
const parent = getRequestContext();
if (parent) {
const previousSettings = parent.siteSettings;
requestSettings.live = parent.routeCacheFill === true;
parent.siteSettings = requestSettings;
return run();
try {
return await run();
} finally {
if (previousSettings) parent.siteSettings = previousSettings;
}
}
return runWithContext({ editMode: false, siteSettings: requestSettings }, run);
});
1 change: 1 addition & 0 deletions packages/core/src/astro/middleware/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled) {
const parent = getRequestContext();
if (parent?.siteSettings) parent.siteSettings.live = true;
if (parent?.routeCacheFill) return render();
return runWithContext({ ...parent, editMode: false, routeCacheFill: true }, render);
}
return render();
Expand Down
209 changes: 200 additions & 9 deletions packages/core/tests/unit/astro/middleware-layout-prefetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { experimental_AstroContainer as AstroContainer } from "astro/container";
import { createAstro, createComponent, render } from "astro/runtime/server/index.js";
import {
type DialectAdapter,
DummyDriver,
Expand All @@ -7,7 +9,10 @@ import {
SqliteIntrospector,
SqliteQueryCompiler,
} from "kysely";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, it, expect, vi } from "vitest";

import { requestCached } from "../../../src/request-cache.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";

vi.mock("astro:middleware", () => ({
defineMiddleware: (handler: unknown) => handler,
Expand All @@ -21,6 +26,7 @@ const { MOCK_RUNTIME, createRequestScopedDb, prefetchLayoutData } = vi.hoisted((
MOCK_RUNTIME: {
storage: { getPublicUrl: vi.fn((key: string) => `https://media.example.com/${key}`) },
db: {},
rooms: null,
hooks: {},
email: null,
configuredPlugins: [],
Expand All @@ -47,9 +53,13 @@ vi.mock(
() => ({ default: { database: { config: { binding: "DB" } }, auth: { mode: "none" } } }),
{ virtual: true },
);
vi.mock("virtual:emdash/dialect", () => ({ createDialect: vi.fn(), createRequestScopedDb }), {
virtual: true,
});
vi.mock(
"virtual:emdash/dialect",
() => ({ createDialect: vi.fn(), createCoalescingDialect: null, createRequestScopedDb }),
{
virtual: true,
},
);
vi.mock("virtual:emdash/media-providers", () => ({ mediaProviders: [] }), { virtual: true });
vi.mock("virtual:emdash/plugins", () => ({ plugins: [] }), { virtual: true });
vi.mock(
Expand All @@ -64,7 +74,17 @@ vi.mock("virtual:emdash/scheduler", () => ({ createScheduler: null }), { virtual

vi.mock("../../../src/emdash-runtime.js", () => ({
DB_INIT_DEADLINE_MS: 30_000,
EmDashRuntime: { create: async () => MOCK_RUNTIME },
EmDashRuntime: {
create: async () =>
new Proxy(MOCK_RUNTIME, {
get: (runtime, key) =>
key === "then"
? undefined
: key in runtime
? Reflect.get(runtime, key)
: async () => ({ success: true }),
}),
},
}));

vi.mock("../../../src/loader.js", () => ({
Expand All @@ -88,14 +108,19 @@ function kyselyWith(adapter: DialectAdapter) {
});
}

function htmlNavigation() {
function htmlNavigation(
path = "/posts/hello",
locals: Record<string, unknown> = {},
properties: Record<string, unknown> = {},
) {
return {
request: new Request("https://example.com/posts/hello", {
request: new Request(`https://example.com${path}`, {
headers: { accept: "text/html,application/xhtml+xml" },
}),
url: new URL("https://example.com/posts/hello"),
url: new URL(`https://example.com${path}`),
cookies: { get: vi.fn(() => undefined), set: vi.fn() },
locals: {} as Record<string, unknown>,
locals,
...properties,
redirect: vi.fn(),
isPrerendered: false,
session: { get: vi.fn(async () => null) },
Expand All @@ -111,8 +136,30 @@ async function navigate() {
}

describe("layout prefetch on anonymous HTML navigations", () => {
let realDb: Awaited<ReturnType<typeof setupTestDatabase>>;
let queryCount: ReturnType<typeof vi.spyOn>;

beforeAll(async () => {
realDb = await setupTestDatabase();
Object.defineProperty(realDb.getExecutor().adapter, "supportsMultipleConnections", {
value: true,
});
queryCount = vi.spyOn(realDb.getExecutor(), "executeQuery");
});

afterAll(async () => {
queryCount.mockRestore();
await teardownTestDatabase(realDb);
});

beforeEach(() => {
vi.clearAllMocks();
queryCount.mockClear();
prefetchLayoutData.mockImplementation(() =>
requestCached("rewrite-layout", async () =>
realDb.selectFrom("_emdash_migrations").selectAll().limit(1).execute(),
).then(() => undefined),
);
});

const adapterWithoutFlag: DialectAdapter = {
Expand All @@ -139,4 +186,148 @@ describe("layout prefetch on anonymous HTML navigations", () => {
expect(createRequestScopedDb).toHaveBeenCalled();
expect(prefetchLayoutData).not.toHaveBeenCalled();
});

async function rewrite(
options: {
targetLocals?: Record<string, unknown>;
targetPath?: string;
targetProperties?: Record<string, unknown>;
} = {},
) {
const scope = { db: realDb, commit: vi.fn(), close: vi.fn() };
createRequestScopedDb.mockReturnValue(scope);
const original = htmlNavigation("/missing-page");
const response = await onRequest(original, async () => {
await requestCached("rewrite-layout", async () =>
realDb.selectFrom("_emdash_migrations").selectAll().limit(1).execute(),
);
const target = htmlNavigation(
options.targetPath ?? "/404",
options.targetLocals ?? original.locals,
options.targetProperties,
);
return onRequest(target, async () => {
await requestCached("rewrite-layout", async () =>
realDb.selectFrom("_emdash_migrations").selectAll().limit(1).execute(),
);
return new Response("ok");
});
});
await response.text();
await new Promise((resolve) => setTimeout(resolve, 10));
return scope;
}

it("does not repeat layout reads through Astro's real rewrite pipeline", async () => {
const scope = { db: realDb, commit: vi.fn(), close: vi.fn() };
createRequestScopedDb.mockReturnValue(scope);
const read = () =>
requestCached("rewrite-layout", async () =>
realDb.selectFrom("_emdash_migrations").selectAll().limit(1).execute(),
);
const notFound = createComponent(async (result, props, slots) => {
const Astro = result.createAstro(createAstro(), props, slots);
Astro.response.status = 404;
await read();
return render`<html><body>Not found</body></html>`;
});
const missing = createComponent(async (result, props, slots) => {
const Astro = result.createAstro(createAstro(), props, slots);
await read();
return Astro.rewrite("/404");
});
const container = await AstroContainer.create({
manifest: {
middleware: async () => ({ onRequest }),
} as NonNullable<Parameters<typeof AstroContainer.create>[0]>["manifest"],
});
container.insertPageRoute("/404", notFound);
const direct = await container.renderToResponse(notFound, {
request: new Request("https://example.com/404", { headers: { accept: "text/html" } }),
});
expect(direct.status).toBe(404);
await direct.text();
await new Promise((resolve) => setTimeout(resolve, 10));
const directQueries = queryCount.mock.calls.length;
vi.clearAllMocks();
const rewritten = await container.renderToResponse(missing, {
request: new Request("https://example.com/missing-page", {
headers: { accept: "text/html" },
}),
});
expect(rewritten.status).toBe(404);
expect(await rewritten.text()).toContain("Not found");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(directQueries).toBe(1);
expect(queryCount).toHaveBeenCalledTimes(directQueries);
expect(prefetchLayoutData).toHaveBeenCalledOnce();
expect(createRequestScopedDb).toHaveBeenCalledOnce();
expect(scope.commit).toHaveBeenCalledOnce();
expect(scope.close).toHaveBeenCalledOnce();
});

it("reuses the prefetched layout read for an anonymous rewrite", async () => {
const scope = await rewrite();

expect(queryCount).toHaveBeenCalledOnce();
expect(prefetchLayoutData).toHaveBeenCalledOnce();
expect(createRequestScopedDb).toHaveBeenCalledOnce();
expect(scope.commit).toHaveBeenCalledOnce();
expect(scope.close).toHaveBeenCalledOnce();
});

it("does not reuse the cache across independent requests", async () => {
await rewrite();
await rewrite();

expect(queryCount).toHaveBeenCalledTimes(2);
expect(prefetchLayoutData).toHaveBeenCalledTimes(2);
expect(createRequestScopedDb).toHaveBeenCalledTimes(2);
});

it("does not reuse the cache when the rewrite has different locals", async () => {
await rewrite({ targetLocals: {} });

expect(queryCount).toHaveBeenCalledTimes(2);
expect(createRequestScopedDb).toHaveBeenCalledTimes(2);
});

it("does not reuse the cache for a different locale target", async () => {
await rewrite({ targetProperties: { currentLocale: "fr" } });

expect(queryCount).toHaveBeenCalledTimes(2);
});

it.each(["/_emdash/api/content", "/sitemap.xml"])(
"initializes runtime handlers for a rewrite to %s",
async (targetPath) => {
await rewrite({ targetPath });
expect(queryCount).toHaveBeenCalledTimes(2);
expect(createRequestScopedDb).toHaveBeenCalledTimes(2);
},
);

it("does not reuse the cache for a preview target", async () => {
await rewrite({ targetPath: "/404?_preview=1" });

expect(queryCount).toHaveBeenCalledTimes(2);
});

it("closes the scoped database when the rewritten render fails", async () => {
const scope = { db: realDb, commit: vi.fn(), close: vi.fn() };
createRequestScopedDb.mockReturnValue(scope);
const original = htmlNavigation("/missing-page");
await expect(
onRequest(original, async () =>
onRequest(htmlNavigation("/404", original.locals, {}), async () => {
throw new Error("render failed");
}),
),
).rejects.toThrow("render failed");
await new Promise((resolve) => setTimeout(resolve, 10));

expect(createRequestScopedDb).toHaveBeenCalledOnce();
expect(scope.commit).toHaveBeenCalledOnce();
expect(scope.close).toHaveBeenCalledOnce();
});
});
Loading
Loading