@decocms/blocks 7.12.1 → 7.15.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/sdk/cachedLoader.ts +110 -32
- package/src/sdk/cachedLoaderModule.test.ts +209 -0
package/package.json
CHANGED
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../middleware/observability";
|
|
20
20
|
import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
|
|
21
21
|
import { withInflightTimeout } from "./inflightTimeout";
|
|
22
|
+
import { RequestContext } from "./requestContext";
|
|
22
23
|
|
|
23
24
|
export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
|
|
24
25
|
|
|
@@ -32,12 +33,26 @@ export interface CachedLoaderOptions {
|
|
|
32
33
|
keyFn?: (props: unknown) => string;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The `@decocms/apps` loader/action module shape: a `default` loader plus the
|
|
38
|
+
* optional `cache` / `cacheKey` exports Fresh loaders ship with.
|
|
39
|
+
*
|
|
40
|
+
* export const cache = "stale-while-revalidate";
|
|
41
|
+
* export const cacheKey = (props, req) => `${new URL(req.url).pathname}...`;
|
|
42
|
+
*
|
|
43
|
+
* `cache` is the OPT-IN: absent (or `"no-store"`) means the loader runs on every
|
|
44
|
+
* invocation, matching `@decocms/apps`' default. Any other value opts the loader
|
|
45
|
+
* into single-flight dedup (concurrent identical calls in one render collapse to
|
|
46
|
+
* one upstream call — the #339 N+1 fix). `cacheKey` computes the dedup identity
|
|
47
|
+
* from `(props, req)`; returning `null` means "do not dedup, run fresh".
|
|
48
|
+
*/
|
|
35
49
|
export interface LoaderModule<TProps = any, TResult = any> {
|
|
36
|
-
default: (props: TProps) => Promise<TResult>;
|
|
50
|
+
default: (props: TProps, req?: Request) => Promise<TResult>;
|
|
37
51
|
cache?: CachePolicy | { maxAge: number };
|
|
38
|
-
cacheKey?: (props: TProps) => string | null;
|
|
52
|
+
cacheKey?: (props: TProps, req?: Request) => string | null;
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
|
|
41
56
|
interface CacheEntry<T = unknown> {
|
|
42
57
|
value: T;
|
|
43
58
|
createdAt: number;
|
|
@@ -307,43 +322,105 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
307
322
|
};
|
|
308
323
|
}
|
|
309
324
|
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Module loader dedup (the `@decocms/apps` cache/cacheKey convention)
|
|
328
|
+
//
|
|
329
|
+
// Distinct from `createCachedLoader` above (VTEX's byte-capped SWR cache): the
|
|
330
|
+
// module path is DEDUP-ONLY. A loader that opts in via `export const cache`
|
|
331
|
+
// gets single-flight — concurrent identical invocations in one render (N
|
|
332
|
+
// sections referencing the same loader) share one upstream call — but nothing
|
|
333
|
+
// is retained past settlement, so there is zero cross-request staleness. This
|
|
334
|
+
// is the deliberately-conservative fix for the #339 N+1 (2–3× product GraphQL
|
|
335
|
+
// per PDP render) without the SWR machinery's stale-serving semantics.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
const moduleInflight = new Map<string, Promise<unknown>>();
|
|
339
|
+
|
|
310
340
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
341
|
+
* Wrap an `@decocms/apps`-shaped loader module so its `cache`/`cacheKey` exports
|
|
342
|
+
* drive single-flight dedup. Loaders without a `cache` export (or `"no-store"`)
|
|
343
|
+
* are returned unwrapped — they run on every call, matching apps' default.
|
|
344
|
+
*
|
|
345
|
+
* The dedup key is `${name}::${cacheKey(props, req)}`; `req` comes from the
|
|
346
|
+
* caller or, on the CMS-resolution path (which passes only props), from
|
|
347
|
+
* `RequestContext.current` — both execute inside the same AsyncLocalStorage
|
|
348
|
+
* scope. `cacheKey` returning `null`, or `req` being unavailable when `cacheKey`
|
|
349
|
+
* needs it, falls back to running fresh / keying on `JSON.stringify(props)`.
|
|
313
350
|
*/
|
|
314
351
|
export function createCachedLoaderFromModule<TProps, TResult>(
|
|
315
352
|
name: string,
|
|
316
353
|
mod: LoaderModule<TProps, TResult>,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
354
|
+
): (props: TProps, req?: Request) => Promise<TResult> {
|
|
355
|
+
const policy: CachePolicy = typeof mod.cache === "string"
|
|
356
|
+
? mod.cache
|
|
357
|
+
: mod.cache && typeof mod.cache === "object"
|
|
358
|
+
? "stale-while-revalidate"
|
|
359
|
+
: "no-store";
|
|
360
|
+
|
|
361
|
+
// Only `stale-while-revalidate` (or `{ maxAge }`) opts into dedup — matching
|
|
362
|
+
// `@decocms/apps`/deco, where `no-store` (the default) and `no-cache` both
|
|
363
|
+
// bypass single-flight and run on every call. Return unwrapped otherwise.
|
|
364
|
+
if (policy !== "stale-while-revalidate") return mod.default;
|
|
365
|
+
|
|
366
|
+
const cacheKeyFn = mod.cacheKey;
|
|
367
|
+
|
|
368
|
+
return (props: TProps, req?: Request): Promise<TResult> => {
|
|
369
|
+
const request = req ?? RequestContext.current?.request ?? undefined;
|
|
370
|
+
|
|
371
|
+
let keyPart: string | null;
|
|
372
|
+
if (cacheKeyFn) {
|
|
373
|
+
// A cacheKey that dereferences `req` (e.g. `new URL(req.url)`) would throw
|
|
374
|
+
// when the request is unavailable — fall back to props-hash dedup instead.
|
|
375
|
+
keyPart = request ? cacheKeyFn(props, request) : JSON.stringify(props);
|
|
376
|
+
} else {
|
|
377
|
+
keyPart = JSON.stringify(props);
|
|
378
|
+
}
|
|
331
379
|
|
|
332
|
-
|
|
380
|
+
// Explicit null → the loader declared this call uncacheable: run fresh.
|
|
381
|
+
if (keyPart === null) return mod.default(props, req);
|
|
333
382
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
383
|
+
const key = `${name}::${keyPart}`;
|
|
384
|
+
const existing = moduleInflight.get(key) as Promise<TResult> | undefined;
|
|
385
|
+
if (existing) return existing;
|
|
386
|
+
|
|
387
|
+
const promise = Promise.resolve(mod.default(props, req)).finally(() => {
|
|
388
|
+
moduleInflight.delete(key);
|
|
389
|
+
});
|
|
390
|
+
moduleInflight.set(key, promise);
|
|
391
|
+
return promise;
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Registry entry factory for generated loader maps (`.deco/loaders.gen.ts`).
|
|
397
|
+
* Lazily imports the loader module on first call, then delegates to a memoized
|
|
398
|
+
* `createCachedLoaderFromModule` wrapper so `cache`/`cacheKey` exports take
|
|
399
|
+
* effect. Keeps generated code a one-liner while the dedup logic stays here,
|
|
400
|
+
* unit-tested, in the framework.
|
|
401
|
+
*/
|
|
402
|
+
export function createLoaderEntry<TProps = any, TResult = any>(
|
|
403
|
+
name: string,
|
|
404
|
+
importFn: () => Promise<LoaderModule<TProps, TResult>>,
|
|
405
|
+
): (props: TProps, req?: Request) => Promise<TResult> {
|
|
406
|
+
// Memoize the import+build PROMISE (not the resolved wrapper) so concurrent
|
|
407
|
+
// first-calls — exactly the N-sections-per-render case — share one import
|
|
408
|
+
// instead of racing into N. Reset on failure so a transient import error can
|
|
409
|
+
// retry rather than poisoning the entry forever.
|
|
410
|
+
let wrappedPromise:
|
|
411
|
+
| Promise<(props: TProps, req?: Request) => Promise<TResult>>
|
|
412
|
+
| undefined;
|
|
413
|
+
return (props: TProps, req?: Request): Promise<TResult> => {
|
|
414
|
+
if (!wrappedPromise) {
|
|
415
|
+
wrappedPromise = importFn()
|
|
416
|
+
.then((mod) => createCachedLoaderFromModule(name, mod))
|
|
417
|
+
.catch((err) => {
|
|
418
|
+
wrappedPromise = undefined;
|
|
419
|
+
throw err;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return wrappedPromise.then((wrapped) => wrapped(props, req));
|
|
423
|
+
};
|
|
347
424
|
}
|
|
348
425
|
|
|
349
426
|
/** Clear all cached entries. Useful for decofile hot-reload. */
|
|
@@ -351,6 +428,7 @@ export function clearLoaderCache() {
|
|
|
351
428
|
cache.clear();
|
|
352
429
|
cacheBytes = 0;
|
|
353
430
|
inflightRequests.clear();
|
|
431
|
+
moduleInflight.clear();
|
|
354
432
|
}
|
|
355
433
|
|
|
356
434
|
/** Get cache stats for diagnostics. */
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearLoaderCache,
|
|
4
|
+
createCachedLoaderFromModule,
|
|
5
|
+
createLoaderEntry,
|
|
6
|
+
type LoaderModule,
|
|
7
|
+
} from "./cachedLoader";
|
|
8
|
+
import { RequestContext } from "./requestContext";
|
|
9
|
+
|
|
10
|
+
/** A loader whose `default` we can count calls on and control resolution of. */
|
|
11
|
+
function makeLoader<T>(result: T) {
|
|
12
|
+
let resolve!: (v: T) => void;
|
|
13
|
+
const gate = new Promise<T>((r) => (resolve = r));
|
|
14
|
+
const fn = vi.fn(async (_props: unknown, _req?: Request) => {
|
|
15
|
+
await gate;
|
|
16
|
+
return result;
|
|
17
|
+
});
|
|
18
|
+
return { fn, release: () => resolve(result) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
clearLoaderCache();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("createCachedLoaderFromModule — dedup-only (no SWR)", () => {
|
|
27
|
+
it("runs on every call when the module has no `cache` export (apps no-store default)", async () => {
|
|
28
|
+
const { fn, release } = makeLoader("x");
|
|
29
|
+
release();
|
|
30
|
+
const mod: LoaderModule = { default: fn };
|
|
31
|
+
const loader = createCachedLoaderFromModule("site/loaders/plain", mod);
|
|
32
|
+
|
|
33
|
+
// No wrap at all → returns the original function identity.
|
|
34
|
+
expect(loader).toBe(fn);
|
|
35
|
+
|
|
36
|
+
await loader({ a: 1 });
|
|
37
|
+
await loader({ a: 1 });
|
|
38
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("runs on every call for `no-cache` (only stale-while-revalidate opts into dedup)", async () => {
|
|
42
|
+
const { fn, release } = makeLoader("x");
|
|
43
|
+
const mod: LoaderModule = { default: fn, cache: "no-cache" };
|
|
44
|
+
const loader = createCachedLoaderFromModule("site/loaders/nocache", mod);
|
|
45
|
+
|
|
46
|
+
expect(loader).toBe(fn);
|
|
47
|
+
const p1 = loader({ a: 1 });
|
|
48
|
+
const p2 = loader({ a: 1 });
|
|
49
|
+
release();
|
|
50
|
+
await Promise.all([p1, p2]);
|
|
51
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("collapses concurrent identical calls into ONE upstream call when `cache` opts in", async () => {
|
|
55
|
+
const { fn, release } = makeLoader("shelf");
|
|
56
|
+
const mod: LoaderModule = { default: fn, cache: "stale-while-revalidate" };
|
|
57
|
+
const loader = createCachedLoaderFromModule("site/loaders/related", mod);
|
|
58
|
+
|
|
59
|
+
// Three sections on one render invoke the same loader concurrently.
|
|
60
|
+
const p1 = loader({ slug: "abc" });
|
|
61
|
+
const p2 = loader({ slug: "abc" });
|
|
62
|
+
const p3 = loader({ slug: "abc" });
|
|
63
|
+
release();
|
|
64
|
+
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
|
65
|
+
|
|
66
|
+
expect(r1).toBe("shelf");
|
|
67
|
+
expect(r2).toBe("shelf");
|
|
68
|
+
expect(r3).toBe("shelf");
|
|
69
|
+
// The #339 N+1 fix: 3 concurrent references → 1 upstream call.
|
|
70
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does NOT retain results across settlement (no cross-request staleness)", async () => {
|
|
74
|
+
const { fn, release } = makeLoader("v");
|
|
75
|
+
release();
|
|
76
|
+
const mod: LoaderModule = { default: fn, cache: "stale-while-revalidate" };
|
|
77
|
+
const loader = createCachedLoaderFromModule("site/loaders/nostale", mod);
|
|
78
|
+
|
|
79
|
+
await loader({ slug: "abc" });
|
|
80
|
+
// Sequential (non-concurrent) call after the first settled → re-runs.
|
|
81
|
+
await loader({ slug: "abc" });
|
|
82
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("dedups by the module's custom cacheKey(props, req), not raw props", async () => {
|
|
86
|
+
const { fn, release } = makeLoader("p");
|
|
87
|
+
const mod: LoaderModule = {
|
|
88
|
+
default: fn,
|
|
89
|
+
cache: "stale-while-revalidate",
|
|
90
|
+
// Key only on pathname — ignores tracking params on the URL.
|
|
91
|
+
cacheKey: (_props, req) => new URL(req!.url).pathname,
|
|
92
|
+
};
|
|
93
|
+
const loader = createCachedLoaderFromModule("site/loaders/pdp", mod);
|
|
94
|
+
|
|
95
|
+
const reqA = new Request("https://x.test/produto?utm_source=a");
|
|
96
|
+
const reqB = new Request("https://x.test/produto?utm_source=b");
|
|
97
|
+
const p1 = loader({ id: 1 }, reqA);
|
|
98
|
+
const p2 = loader({ id: 2 }, reqB); // different props + url, same pathname
|
|
99
|
+
release();
|
|
100
|
+
await Promise.all([p1, p2]);
|
|
101
|
+
|
|
102
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("runs fresh (no dedup) when cacheKey returns null", async () => {
|
|
106
|
+
const { fn, release } = makeLoader("p");
|
|
107
|
+
const mod: LoaderModule = {
|
|
108
|
+
default: fn,
|
|
109
|
+
cache: "stale-while-revalidate",
|
|
110
|
+
cacheKey: () => null,
|
|
111
|
+
};
|
|
112
|
+
const loader = createCachedLoaderFromModule("site/loaders/personalized", mod);
|
|
113
|
+
|
|
114
|
+
const req = new Request("https://x.test/");
|
|
115
|
+
const p1 = loader({ id: 1 }, req);
|
|
116
|
+
const p2 = loader({ id: 1 }, req);
|
|
117
|
+
release();
|
|
118
|
+
await Promise.all([p1, p2]);
|
|
119
|
+
|
|
120
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("sources req from RequestContext when the caller passes none (CMS-resolution path)", async () => {
|
|
124
|
+
const { fn, release } = makeLoader("p");
|
|
125
|
+
const seen: string[] = [];
|
|
126
|
+
const mod: LoaderModule = {
|
|
127
|
+
default: fn,
|
|
128
|
+
cache: "stale-while-revalidate",
|
|
129
|
+
cacheKey: (_props, req) => {
|
|
130
|
+
seen.push(new URL(req!.url).pathname);
|
|
131
|
+
return new URL(req!.url).pathname;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const loader = createCachedLoaderFromModule("site/loaders/ctx", mod);
|
|
135
|
+
|
|
136
|
+
const req = new Request("https://x.test/categoria");
|
|
137
|
+
await RequestContext.run(req, async () => {
|
|
138
|
+
// Called with ONLY props, mirroring resolve.ts internalResolve.
|
|
139
|
+
const p1 = loader({ a: 1 });
|
|
140
|
+
const p2 = loader({ a: 1 });
|
|
141
|
+
release();
|
|
142
|
+
await Promise.all([p1, p2]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(seen).toContain("/categoria");
|
|
146
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("falls back to props-hash dedup when cacheKey needs req but none is available", async () => {
|
|
150
|
+
const { fn, release } = makeLoader("p");
|
|
151
|
+
const mod: LoaderModule = {
|
|
152
|
+
default: fn,
|
|
153
|
+
cache: "stale-while-revalidate",
|
|
154
|
+
// Would throw on undefined req — the wrapper must not call it without one.
|
|
155
|
+
cacheKey: (_props, req) => new URL(req!.url).pathname,
|
|
156
|
+
};
|
|
157
|
+
const loader = createCachedLoaderFromModule("site/loaders/noreq", mod);
|
|
158
|
+
|
|
159
|
+
// No req arg, no RequestContext scope.
|
|
160
|
+
const p1 = loader({ a: 1 });
|
|
161
|
+
const p2 = loader({ a: 1 });
|
|
162
|
+
release();
|
|
163
|
+
await expect(Promise.all([p1, p2])).resolves.toEqual(["p", "p"]);
|
|
164
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("propagates loader errors and clears the in-flight entry", async () => {
|
|
168
|
+
const err = new Error("upstream down");
|
|
169
|
+
const fn = vi.fn(async () => {
|
|
170
|
+
throw err;
|
|
171
|
+
});
|
|
172
|
+
const mod: LoaderModule = { default: fn, cache: "stale-while-revalidate" };
|
|
173
|
+
const loader = createCachedLoaderFromModule("site/loaders/boom", mod);
|
|
174
|
+
|
|
175
|
+
await expect(loader({ a: 1 })).rejects.toThrow("upstream down");
|
|
176
|
+
// A retry after failure is not blocked by a stuck in-flight entry.
|
|
177
|
+
await expect(loader({ a: 1 })).rejects.toThrow("upstream down");
|
|
178
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("createLoaderEntry — lazy import + wrap", () => {
|
|
183
|
+
it("imports the module once and dedups concurrent calls", async () => {
|
|
184
|
+
const { fn, release } = makeLoader("lazy");
|
|
185
|
+
const importFn = vi.fn(async () => ({
|
|
186
|
+
default: fn,
|
|
187
|
+
cache: "stale-while-revalidate" as const,
|
|
188
|
+
}));
|
|
189
|
+
const entry = createLoaderEntry("site/loaders/lazy", importFn);
|
|
190
|
+
|
|
191
|
+
const p1 = entry({ a: 1 });
|
|
192
|
+
const p2 = entry({ a: 1 });
|
|
193
|
+
release();
|
|
194
|
+
await Promise.all([p1, p2]);
|
|
195
|
+
|
|
196
|
+
expect(importFn).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("passes non-opted-in loaders straight through", async () => {
|
|
201
|
+
const { fn, release } = makeLoader("plain");
|
|
202
|
+
release();
|
|
203
|
+
const entry = createLoaderEntry("site/loaders/plain", async () => ({ default: fn }));
|
|
204
|
+
|
|
205
|
+
await entry({ a: 1 });
|
|
206
|
+
await entry({ a: 1 });
|
|
207
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
208
|
+
});
|
|
209
|
+
});
|