@decocms/blocks 7.60.0 → 7.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cms/index.ts +4 -0
- package/src/cms/resolve.test.ts +161 -0
- package/src/cms/resolve.ts +133 -14
package/package.json
CHANGED
package/src/cms/index.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type {
|
|
|
85
85
|
DanglingReferenceHandler,
|
|
86
86
|
DecoPageResult,
|
|
87
87
|
DeferredSection,
|
|
88
|
+
Deferred,
|
|
88
89
|
MatcherContext,
|
|
89
90
|
PageSeo,
|
|
90
91
|
ResolvedSection,
|
|
@@ -97,10 +98,12 @@ export {
|
|
|
97
98
|
clearCommerceLoaders,
|
|
98
99
|
evaluateMatcher,
|
|
99
100
|
extractSeoFromProps,
|
|
101
|
+
asResolved,
|
|
100
102
|
extractSeoFromSections,
|
|
101
103
|
getAsyncRenderingConfig,
|
|
102
104
|
getDeferredRawProps,
|
|
103
105
|
isBot,
|
|
106
|
+
isDeferred,
|
|
104
107
|
isEagerRequest,
|
|
105
108
|
isSeoSection,
|
|
106
109
|
onBeforeResolve,
|
|
@@ -113,6 +116,7 @@ export {
|
|
|
113
116
|
registerNeverDeferSections,
|
|
114
117
|
registerSeoSections,
|
|
115
118
|
resolveDecoPage,
|
|
119
|
+
resolveDeferred,
|
|
116
120
|
resolveDeferredSection,
|
|
117
121
|
resolveDeferredSectionFull,
|
|
118
122
|
resolvePageSections,
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -25,14 +25,18 @@ import { findPageByPath } from "./loader";
|
|
|
25
25
|
import { getSection } from "./registry";
|
|
26
26
|
import type { AsyncRenderingConfig, DeferredSection, MatcherContext } from "./resolve";
|
|
27
27
|
import {
|
|
28
|
+
asResolved,
|
|
28
29
|
clearCommerceLoaders,
|
|
30
|
+
layoutCacheKey,
|
|
29
31
|
DEFAULT_FOLD_THRESHOLD,
|
|
30
32
|
extractSeoFromProps,
|
|
31
33
|
getAsyncRenderingConfig,
|
|
34
|
+
isDeferred,
|
|
32
35
|
isEagerRequest,
|
|
33
36
|
reExtractRawProps,
|
|
34
37
|
registerCommerceLoader,
|
|
35
38
|
registerMatcher,
|
|
39
|
+
resolveDeferred,
|
|
36
40
|
registerEagerSections,
|
|
37
41
|
registerAlwaysDeferSections,
|
|
38
42
|
registerNeverDeferSections,
|
|
@@ -1170,3 +1174,160 @@ describe("hidden array items (never matcher)", () => {
|
|
|
1170
1174
|
expect(result.items).toEqual([null, { label: "kept" }]);
|
|
1171
1175
|
});
|
|
1172
1176
|
});
|
|
1177
|
+
|
|
1178
|
+
// ---------------------------------------------------------------------------
|
|
1179
|
+
// resolved-layout cache — device is part of the key
|
|
1180
|
+
// ---------------------------------------------------------------------------
|
|
1181
|
+
//
|
|
1182
|
+
// The key used to be the component path alone, so the first visitor's variant
|
|
1183
|
+
// was served to everyone for the whole TTL. A real store had to leave Header
|
|
1184
|
+
// and Footer OUT of registerLayoutSections because of it, and then re-resolved
|
|
1185
|
+
// both on every page and every navigation. These cases pin the axis; without
|
|
1186
|
+
// them a refactor could silently collapse the key again and the symptom (a
|
|
1187
|
+
// mobile header on desktop) only shows up in production, minutes later.
|
|
1188
|
+
|
|
1189
|
+
describe("layoutCacheKey — the resolved-layout cache is segmented by device", () => {
|
|
1190
|
+
const MOBILE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile Safari";
|
|
1191
|
+
const TABLET_UA = "Mozilla/5.0 (iPad; CPU OS 17_0) Safari/605";
|
|
1192
|
+
const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120 Safari/537.36";
|
|
1193
|
+
const KEY = "site/sections/Header.tsx";
|
|
1194
|
+
|
|
1195
|
+
// The key used to be the component path alone. A site whose Header takes a
|
|
1196
|
+
// per-request device prop therefore served the first visitor's variant to
|
|
1197
|
+
// everyone for the whole 5-minute TTL — a mobile header on desktop. The only
|
|
1198
|
+
// escape was leaving Header/Footer out of `registerLayoutSections`, which is
|
|
1199
|
+
// what a real store did, and it then re-resolved both on every page and every
|
|
1200
|
+
// navigation.
|
|
1201
|
+
|
|
1202
|
+
it("mobile, tablet and desktop are three distinct keys", () => {
|
|
1203
|
+
const keys = [MOBILE_UA, TABLET_UA, DESKTOP_UA].map((ua) => layoutCacheKey(KEY, { userAgent: ua }));
|
|
1204
|
+
expect(new Set(keys).size).toBe(3);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it("the same device yields the same key — the cache still works", () => {
|
|
1208
|
+
expect(layoutCacheKey(KEY, { userAgent: DESKTOP_UA })).toBe(
|
|
1209
|
+
layoutCacheKey(KEY, { userAgent: DESKTOP_UA }),
|
|
1210
|
+
);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("different components never share a key on the same device", () => {
|
|
1214
|
+
expect(layoutCacheKey("site/sections/Header.tsx", { userAgent: MOBILE_UA })).not.toBe(
|
|
1215
|
+
layoutCacheKey("site/sections/Footer.tsx", { userAgent: MOBILE_UA }),
|
|
1216
|
+
);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it("a missing userAgent still produces a key (no crash, no undefined axis)", () => {
|
|
1220
|
+
const noCtx = layoutCacheKey(KEY);
|
|
1221
|
+
expect(noCtx).toContain(KEY);
|
|
1222
|
+
expect(noCtx).not.toContain("undefined");
|
|
1223
|
+
// An absent UA must land on the SAME bucket as a request whose UA is empty,
|
|
1224
|
+
// so a health check and a real desktop hit do not fragment the cache twice.
|
|
1225
|
+
expect(noCtx).toBe(layoutCacheKey(KEY, { userAgent: "" }));
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// ---------------------------------------------------------------------------
|
|
1230
|
+
// asResolved / deferred props
|
|
1231
|
+
// ---------------------------------------------------------------------------
|
|
1232
|
+
//
|
|
1233
|
+
// The CMS resolver is recursive and resolves EVERY `__resolveType` it finds in a
|
|
1234
|
+
// section's props — including branches the section will not render. Measured on
|
|
1235
|
+
// a real store, a PDP's `notFoundSections` (the 404 fallback) carried a shelf
|
|
1236
|
+
// whose `products` was a full-text search: 1703ms of the eager critical path of
|
|
1237
|
+
// every product page, result thrown away. `asResolved(v, true)` is the opt-out.
|
|
1238
|
+
|
|
1239
|
+
describe("asResolved — deferred props", () => {
|
|
1240
|
+
const LOADER = "test/loaders/expensive.ts";
|
|
1241
|
+
let calls = 0;
|
|
1242
|
+
|
|
1243
|
+
beforeEach(() => {
|
|
1244
|
+
calls = 0;
|
|
1245
|
+
clearCommerceLoaders();
|
|
1246
|
+
registerCommerceLoader(LOADER, async () => {
|
|
1247
|
+
calls += 1;
|
|
1248
|
+
return { products: ["p1", "p2"] };
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
afterEach(() => clearCommerceLoaders());
|
|
1253
|
+
|
|
1254
|
+
const expensive = { __resolveType: LOADER };
|
|
1255
|
+
|
|
1256
|
+
it("without asResolved the loader runs during the page pass", async () => {
|
|
1257
|
+
const out = (await resolveValue({ fallback: expensive })) as any;
|
|
1258
|
+
expect(calls).toBe(1);
|
|
1259
|
+
expect(out.fallback).toEqual({ products: ["p1", "p2"] });
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("asResolved(v) hands the value back untouched — the loader never runs", async () => {
|
|
1263
|
+
const out = (await resolveValue({ fallback: asResolved(expensive) })) as any;
|
|
1264
|
+
expect(calls).toBe(0);
|
|
1265
|
+
// The raw node comes through, NOT its resolution.
|
|
1266
|
+
expect(out.fallback).toEqual(expensive);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("asResolved(v, true) yields a thunk and does NOT resolve until called", async () => {
|
|
1270
|
+
const out = (await resolveValue({ fallback: asResolved(expensive, true) })) as any;
|
|
1271
|
+
|
|
1272
|
+
expect(calls).toBe(0);
|
|
1273
|
+
expect(isDeferred(out.fallback)).toBe(true);
|
|
1274
|
+
|
|
1275
|
+
expect(await resolveDeferred(out.fallback)).toEqual({ products: ["p1", "p2"] });
|
|
1276
|
+
expect(calls).toBe(1);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("the branch that never calls the thunk pays nothing", async () => {
|
|
1280
|
+
const out = (await resolveValue({
|
|
1281
|
+
shown: expensive,
|
|
1282
|
+
fallback: asResolved(expensive, true),
|
|
1283
|
+
})) as any;
|
|
1284
|
+
// Only the rendered branch resolved. This is the whole point: 1 upstream
|
|
1285
|
+
// call instead of 2 for a page that renders one of the two.
|
|
1286
|
+
expect(calls).toBe(1);
|
|
1287
|
+
expect(out.shown).toEqual({ products: ["p1", "p2"] });
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it("a thunk is dropped by JSON.stringify — the unused branch leaves the payload too", async () => {
|
|
1291
|
+
const out = (await resolveValue({ fallback: asResolved(expensive, true) })) as any;
|
|
1292
|
+
expect(JSON.parse(JSON.stringify(out))).toEqual({});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it("resolveDeferred is tolerant of a plain value (admin preview path)", async () => {
|
|
1296
|
+
// The admin preview does not run `onBeforeResolveProps`, so the prop arrives
|
|
1297
|
+
// already resolved. A section must read the same either way.
|
|
1298
|
+
expect(await resolveDeferred([{ a: 1 }])).toEqual([{ a: 1 }]);
|
|
1299
|
+
expect(await resolveDeferred(undefined)).toBeUndefined();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("the thunk resolves in THIS request's context, not a context-free one", async () => {
|
|
1303
|
+
// The invariant that makes deferring safe: a cookie/UA-gated prop must not
|
|
1304
|
+
// silently resolve against an anonymous request when the section calls it.
|
|
1305
|
+
const PROBE = "test/matchers/deferredProbe.ts";
|
|
1306
|
+
const seen: MatcherContext[] = [];
|
|
1307
|
+
registerMatcher(PROBE, (_rule, ctx) => {
|
|
1308
|
+
seen.push(ctx);
|
|
1309
|
+
return true;
|
|
1310
|
+
});
|
|
1311
|
+
const gated = {
|
|
1312
|
+
__resolveType: WELL_KNOWN_TYPES.MULTIVARIATE,
|
|
1313
|
+
variants: [{ rule: { __resolveType: PROBE }, value: expensive }],
|
|
1314
|
+
};
|
|
1315
|
+
const ctx: MatcherContext = {
|
|
1316
|
+
userAgent: "Mozilla/5.0 (iPhone) Mobile Safari",
|
|
1317
|
+
url: "https://store.com/x/p",
|
|
1318
|
+
cookies: { VtexIdclientAutCookie: "tok" },
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const out = (await resolveValue({ fallback: asResolved(gated, true) }, undefined, ctx)) as any;
|
|
1322
|
+
expect(seen).toHaveLength(0); // nothing evaluated yet
|
|
1323
|
+
|
|
1324
|
+
await resolveDeferred(out.fallback);
|
|
1325
|
+
|
|
1326
|
+
expect(seen).not.toHaveLength(0);
|
|
1327
|
+
for (const c of seen) {
|
|
1328
|
+
expect(c.cookies).toEqual(ctx.cookies);
|
|
1329
|
+
expect(c.userAgent).toBe(ctx.userAgent);
|
|
1330
|
+
expect(c.url).toBe(ctx.url);
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
});
|
package/src/cms/resolve.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
registerActionSchemas,
|
|
15
15
|
registerLoaderSchemas,
|
|
16
16
|
} from "./schema";
|
|
17
|
+
import { detectDevice } from "../sdk/detectDevice";
|
|
17
18
|
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
18
19
|
|
|
19
20
|
// globalThis-backed: share state across Vite server function split modules
|
|
@@ -868,8 +869,38 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
868
869
|
|
|
869
870
|
const childCtx: ResolveContext = { ...rctx, depth: rctx.depth + 1 };
|
|
870
871
|
|
|
871
|
-
//
|
|
872
|
-
|
|
872
|
+
// ── `asResolved` ─────────────────────────────────────────────────────────
|
|
873
|
+
//
|
|
874
|
+
// `{ __resolveType: "resolved", data }` hands `data` back untouched — the
|
|
875
|
+
// caller is telling us it is already resolved.
|
|
876
|
+
//
|
|
877
|
+
// With `deferred: true` it instead becomes a THUNK, and `data` is not walked
|
|
878
|
+
// at all during the page pass. That is the point: the CMS resolver is
|
|
879
|
+
// recursive and resolves every `__resolveType` it finds in a section's props,
|
|
880
|
+
// including branches the section will not render. Measured on a real store, a
|
|
881
|
+
// PDP's `notFoundSections` — the 404 fallback — carried a shelf whose
|
|
882
|
+
// `products` was a full-text search: 1703ms of the EAGER critical path of
|
|
883
|
+
// EVERY product page, with the result thrown away. Same shape applies to tab
|
|
884
|
+
// content, modal content and unmatched A/B branches.
|
|
885
|
+
//
|
|
886
|
+
// The thunk closes over `childCtx`, so resolving it later still sees THIS
|
|
887
|
+
// request: cookies, UA and url reach the matchers, and `rctx.memo` dedupes
|
|
888
|
+
// named-block references the page already resolved. It does NOT dedupe
|
|
889
|
+
// commerce loaders — those are keyed by their own cache layer, not the memo
|
|
890
|
+
// (see the "Named block reference (memoized)" branch), so a deferred prop
|
|
891
|
+
// pointing at the same loader as a rendered one still calls it twice.
|
|
892
|
+
//
|
|
893
|
+
// HAZARD, and it is why this is opt-in per prop: a thunk cannot be
|
|
894
|
+
// serialized. If a section defers a prop and never calls it, the prop reaches
|
|
895
|
+
// the client as `undefined` (JSON.stringify drops functions). Defer only what
|
|
896
|
+
// the section itself resolves, and only on the branch that needs it.
|
|
897
|
+
if (resolveType === "resolved") {
|
|
898
|
+
if (obj.deferred === true) {
|
|
899
|
+
const raw = obj.data;
|
|
900
|
+
return () => internalResolve(raw, childCtx);
|
|
901
|
+
}
|
|
902
|
+
return obj.data ?? null;
|
|
903
|
+
}
|
|
873
904
|
|
|
874
905
|
// Lazy section wrapper — unwrap single inner section
|
|
875
906
|
if (resolveType === WELL_KNOWN_TYPES.LAZY) {
|
|
@@ -1105,6 +1136,33 @@ interface ResolvedSectionsCache {
|
|
|
1105
1136
|
const resolvedLayoutCache = new Map<string, ResolvedSectionsCache>();
|
|
1106
1137
|
const resolvedLayoutInflight = new Map<string, Promise<ResolvedSection[]>>();
|
|
1107
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* Cache key for a resolved layout block, segmented by DEVICE.
|
|
1141
|
+
*
|
|
1142
|
+
* The key used to be the component path alone, and that made the cache unusable
|
|
1143
|
+
* for any site whose Header/Footer takes a per-request device prop: the first
|
|
1144
|
+
* visitor's variant was served to everyone for the whole TTL (mobile header on
|
|
1145
|
+
* desktop and vice-versa). The only escape was to leave those sections out of
|
|
1146
|
+
* `registerLayoutSections` entirely — which is what a real store did, and it
|
|
1147
|
+
* then re-resolved Header and Footer on every single page and navigation.
|
|
1148
|
+
*
|
|
1149
|
+
* Device is the right default axis because it is the one thing the framework
|
|
1150
|
+
* itself injects into every section (`sectionLoaderContext` → `detectDevice`),
|
|
1151
|
+
* so a layout section can depend on it WITHOUT the site opting into anything.
|
|
1152
|
+
* A layout that does not vary by device just gets up to 3 identical entries —
|
|
1153
|
+
* a bounded, negligible cost against silently serving the wrong markup.
|
|
1154
|
+
*
|
|
1155
|
+
* Anything else a layout varies on (region, sales channel, locale) is
|
|
1156
|
+
* site-specific and NOT covered here: those sections should stay out of
|
|
1157
|
+
* `registerLayoutSections` until there is a demonstrated need for a `vary`
|
|
1158
|
+
* hook. Adding axes nobody asked for only shrinks the hit rate.
|
|
1159
|
+
*
|
|
1160
|
+
* Exported for unit testing.
|
|
1161
|
+
*/
|
|
1162
|
+
export function layoutCacheKey(blockKey: string, matcherCtx?: MatcherContext): string {
|
|
1163
|
+
return `${blockKey}::${detectDevice(matcherCtx?.userAgent ?? "")}`;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1108
1166
|
function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
|
|
1109
1167
|
const entry = resolvedLayoutCache.get(blockKey);
|
|
1110
1168
|
if (!entry) return null;
|
|
@@ -1754,6 +1812,65 @@ export async function resolveSectionsList(
|
|
|
1754
1812
|
return [];
|
|
1755
1813
|
}
|
|
1756
1814
|
|
|
1815
|
+
// ---------------------------------------------------------------------------
|
|
1816
|
+
// Deferred props (`asResolved`)
|
|
1817
|
+
// ---------------------------------------------------------------------------
|
|
1818
|
+
|
|
1819
|
+
/** A prop deferred with `asResolved(value, true)`: call it to resolve. */
|
|
1820
|
+
export type Deferred<T> = () => Promise<T>;
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Mark a prop as already-resolved, or defer its resolution to the section.
|
|
1824
|
+
*
|
|
1825
|
+
* Use it from a section's `onBeforeResolveProps`, which runs on the RAW CMS
|
|
1826
|
+
* props before the resolver walks them:
|
|
1827
|
+
*
|
|
1828
|
+
* ```ts
|
|
1829
|
+
* export const onBeforeResolveProps = (props: Props) => ({
|
|
1830
|
+
* ...props,
|
|
1831
|
+
* // The 404 fallback: only the branch that renders it should pay for it.
|
|
1832
|
+
* notFoundSections: asResolved(props.notFoundSections, true),
|
|
1833
|
+
* })
|
|
1834
|
+
*
|
|
1835
|
+
* export const loader = async (props: Props) => {
|
|
1836
|
+
* if (props.page) return { ...props, notFoundSections: [] }
|
|
1837
|
+
* return { ...props, notFoundSections: await resolveDeferred(props.notFoundSections) }
|
|
1838
|
+
* }
|
|
1839
|
+
* ```
|
|
1840
|
+
*
|
|
1841
|
+
* - `asResolved(v)` — hand `v` back untouched; the resolver does not walk it.
|
|
1842
|
+
* - `asResolved(v, true)` — the section receives a {@link Deferred} thunk and
|
|
1843
|
+
* decides whether to pay for it.
|
|
1844
|
+
*
|
|
1845
|
+
* A deferred prop that is never called reaches the client as `undefined`: a
|
|
1846
|
+
* thunk cannot be serialized. That is deliberate — it is what keeps the unused
|
|
1847
|
+
* branch out of the hydration payload too — but it means you must resolve it on
|
|
1848
|
+
* the branch that renders it.
|
|
1849
|
+
*/
|
|
1850
|
+
export function asResolved<T>(value: T, defer?: boolean): T {
|
|
1851
|
+
return {
|
|
1852
|
+
__resolveType: "resolved",
|
|
1853
|
+
data: value,
|
|
1854
|
+
...(defer ? { deferred: true } : {}),
|
|
1855
|
+
} as unknown as T;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** True when a prop came through as a {@link Deferred} thunk. */
|
|
1859
|
+
export function isDeferred<T>(value: unknown): value is Deferred<T> {
|
|
1860
|
+
return typeof value === "function";
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Resolve a possibly-deferred prop.
|
|
1865
|
+
*
|
|
1866
|
+
* Tolerant of a plain value, so a section reads the same whether the prop was
|
|
1867
|
+
* deferred, marked already-resolved, or left alone — and so the admin preview
|
|
1868
|
+
* (which does not run `onBeforeResolveProps`) keeps working.
|
|
1869
|
+
*/
|
|
1870
|
+
export async function resolveDeferred<T>(value: Deferred<T> | T): Promise<T> {
|
|
1871
|
+
return isDeferred<T>(value) ? await value() : (value as T);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1757
1874
|
// ---------------------------------------------------------------------------
|
|
1758
1875
|
// Page-level SEO — extracted from registered SEO sections after resolution
|
|
1759
1876
|
// ---------------------------------------------------------------------------
|
|
@@ -2127,20 +2244,21 @@ async function resolveDecoPageImpl(
|
|
|
2127
2244
|
const layoutKey = isRawSectionLayout(section);
|
|
2128
2245
|
|
|
2129
2246
|
if (layoutKey) {
|
|
2130
|
-
const
|
|
2247
|
+
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2248
|
+
const cached = getCachedResolvedLayout(cacheKey);
|
|
2131
2249
|
if (cached) return cached;
|
|
2132
2250
|
|
|
2133
|
-
const inflight = resolvedLayoutInflight.get(
|
|
2251
|
+
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
2134
2252
|
if (inflight) return inflight;
|
|
2135
2253
|
|
|
2136
2254
|
const p = withInflightTimeout(
|
|
2137
2255
|
resolveRawSection(section, rctx).then((results) => {
|
|
2138
|
-
setCachedResolvedLayout(
|
|
2256
|
+
setCachedResolvedLayout(cacheKey, results);
|
|
2139
2257
|
return results;
|
|
2140
2258
|
}),
|
|
2141
|
-
`resolvedLayout ${
|
|
2142
|
-
).finally(() => resolvedLayoutInflight.delete(
|
|
2143
|
-
resolvedLayoutInflight.set(
|
|
2259
|
+
`resolvedLayout ${cacheKey}`,
|
|
2260
|
+
).finally(() => resolvedLayoutInflight.delete(cacheKey));
|
|
2261
|
+
resolvedLayoutInflight.set(cacheKey, p);
|
|
2144
2262
|
return p;
|
|
2145
2263
|
}
|
|
2146
2264
|
|
|
@@ -2224,20 +2342,21 @@ export async function resolvePageSections(
|
|
|
2224
2342
|
const layoutKey = isRawSectionLayout(section);
|
|
2225
2343
|
|
|
2226
2344
|
if (layoutKey) {
|
|
2227
|
-
const
|
|
2345
|
+
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2346
|
+
const cached = getCachedResolvedLayout(cacheKey);
|
|
2228
2347
|
if (cached) return cached;
|
|
2229
2348
|
|
|
2230
|
-
const inflight = resolvedLayoutInflight.get(
|
|
2349
|
+
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
2231
2350
|
if (inflight) return inflight;
|
|
2232
2351
|
|
|
2233
2352
|
const p = withInflightTimeout(
|
|
2234
2353
|
resolveRawSection(section, rctx).then((results) => {
|
|
2235
|
-
setCachedResolvedLayout(
|
|
2354
|
+
setCachedResolvedLayout(cacheKey, results);
|
|
2236
2355
|
return results;
|
|
2237
2356
|
}),
|
|
2238
|
-
`resolvedLayout ${
|
|
2239
|
-
).finally(() => resolvedLayoutInflight.delete(
|
|
2240
|
-
resolvedLayoutInflight.set(
|
|
2357
|
+
`resolvedLayout ${cacheKey}`,
|
|
2358
|
+
).finally(() => resolvedLayoutInflight.delete(cacheKey));
|
|
2359
|
+
resolvedLayoutInflight.set(cacheKey, p);
|
|
2241
2360
|
return p;
|
|
2242
2361
|
}
|
|
2243
2362
|
|