diff --git a/website/sections/Rendering/Lazy.tsx b/website/sections/Rendering/Lazy.tsx index 65e885eef..bece98fa1 100644 --- a/website/sections/Rendering/Lazy.tsx +++ b/website/sections/Rendering/Lazy.tsx @@ -6,6 +6,7 @@ import { } from "@deco/deco/blocks"; import { useContext } from "preact/hooks"; import { shouldForceRender } from "../../../utils/deferred.ts"; +import { newIsolatedRequestState } from "../../utils/isolatedRequestState.ts"; import type { AppContext } from "../../mod.ts"; const useSectionContext = () => useContext(SectionCtx); @@ -83,7 +84,14 @@ export const loader = async (props: Props, req: Request, ctx: AppContext) => { const resolveSection = isDeferred
(section) ? RequestContext.bind({ signal: abortController.signal }, section) : () => section; - const resolvedSection = await resolveSection({}, { + // This resolution only discovers the selected section's loading fallback. + // Keep its loaders, matchers and response mutations from affecting the + // cache decision of the parent page. The eager partial resolves the section + // again with the real request state and remains personalized when needed. + const isolatedState = newIsolatedRequestState() as unknown as Parameters< + typeof resolveSection + >[0]; + const resolvedSection = await resolveSection(isolatedState, { propagateOptions: true, hooks: { onPropsResolveStart: (resolve, _props, resolver) => { diff --git a/website/utils/isolatedRequestState.test.ts b/website/utils/isolatedRequestState.test.ts new file mode 100644 index 000000000..6cc24516d --- /dev/null +++ b/website/utils/isolatedRequestState.test.ts @@ -0,0 +1,30 @@ +import { assertEquals, assertNotStrictEquals } from "@std/assert"; +import { newIsolatedRequestState } from "./isolatedRequestState.ts"; + +Deno.test("newIsolatedRequestState does not share resolver side effects", () => { + const first = newIsolatedRequestState(); + const second = newIsolatedRequestState(); + + first.vary.shouldCache = false; + first.vary.push("weather/loaders/temperature.ts", "rio-de-janeiro"); + first.flags.push({ + name: "weather", + value: true, + isSegment: false, + }); + first.response.headers.set("set-cookie", "personalized=true"); + + assertNotStrictEquals(first.vary, second.vary); + assertNotStrictEquals(first.flags, second.flags); + assertNotStrictEquals(first.response, second.response); + assertNotStrictEquals(first.bag, second.bag); + assertEquals(first.vary.shouldCache, false); + assertEquals( + first.vary.build(), + "rio-de-janeiro,weather/loaders/temperature.ts", + ); + assertEquals(second.vary.shouldCache, true); + assertEquals(second.vary.build(), ""); + assertEquals(second.flags, []); + assertEquals(second.response.headers.has("set-cookie"), false); +}); diff --git a/website/utils/isolatedRequestState.ts b/website/utils/isolatedRequestState.ts new file mode 100644 index 000000000..dac00f6c6 --- /dev/null +++ b/website/utils/isolatedRequestState.ts @@ -0,0 +1,37 @@ +import type { AppContext } from "../mod.ts"; + +type IsolatedRequestState = Pick< + AppContext, + "bag" | "flags" | "response" | "vary" +>; + +/** + * Creates request-scoped state for speculative/deferred resolutions. + * + * Resolvers can mutate cache eligibility, flags, response headers and the + * request bag. Those mutations must not leak when the resolved value is used + * only to inspect metadata such as a loading fallback. + */ +export const newIsolatedRequestState = (): IsolatedRequestState => { + const keys: string[] = []; + const debugEntries: unknown[] = []; + + return { + bag: new WeakMap(), + flags: [], + response: { headers: new Headers() }, + vary: { + shouldCache: true, + push: (...key: string[]) => { + keys.push(...key); + }, + build: () => keys.sort().join(), + debug: { + push: (entry: T) => { + debugEntries.push(entry); + }, + build: () => debugEntries as T[], + }, + }, + }; +};