@decocms/blocks 7.14.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.14.0",
3
+ "version": "7.15.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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
- * Create a cached loader from a module that exports `cache` and/or `cacheKey`.
312
- * Falls back to the provided defaults if the module doesn't declare them.
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
- defaults?: Partial<CachedLoaderOptions>,
318
- ): (props: TProps) => Promise<TResult> {
319
- const moduleCache = mod.cache;
320
- let policy: CachePolicy;
321
- let maxAge: number | undefined;
322
-
323
- if (typeof moduleCache === "string") {
324
- policy = moduleCache;
325
- } else if (moduleCache && typeof moduleCache === "object") {
326
- policy = "stale-while-revalidate";
327
- maxAge = moduleCache.maxAge;
328
- } else {
329
- policy = defaults?.policy ?? "stale-while-revalidate";
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
- maxAge = maxAge ?? defaults?.maxAge;
380
+ // Explicit null the loader declared this call uncacheable: run fresh.
381
+ if (keyPart === null) return mod.default(props, req);
333
382
 
334
- const keyFn = mod.cacheKey
335
- ? (props: unknown) => {
336
- const key = mod.cacheKey!(props as TProps);
337
- return key ?? JSON.stringify(props);
338
- }
339
- : defaults?.keyFn;
340
-
341
- return createCachedLoader(name, mod.default, {
342
- policy,
343
- maxAge,
344
- staleIfError: defaults?.staleIfError,
345
- keyFn,
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
+ });