@decocms/blocks 7.20.10 → 7.21.1

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.20.10",
3
+ "version": "7.21.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { clearLoaderCache, createCachedLoader, getLoaderCacheStats } from "./cachedLoader";
3
+
4
+ describe("createCachedLoader", () => {
5
+ beforeEach(() => {
6
+ clearLoaderCache();
7
+ });
8
+ afterEach(() => {
9
+ clearLoaderCache();
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ it("serves a fresh entry from cache (MISS then HIT) within maxAge", async () => {
14
+ const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
15
+ const cached = createCachedLoader("t/basic", loaderFn, {
16
+ policy: "stale-while-revalidate",
17
+ maxAge: 60_000,
18
+ });
19
+
20
+ await cached({ id: 1 }); // MISS
21
+ await cached({ id: 1 }); // HIT — same key, within maxAge
22
+
23
+ expect(loaderFn).toHaveBeenCalledTimes(1);
24
+ expect(getLoaderCacheStats().entries).toBe(1);
25
+ });
26
+
27
+ it("distinct props produce distinct entries", async () => {
28
+ const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
29
+ const cached = createCachedLoader("t/props", loaderFn, {
30
+ policy: "stale-while-revalidate",
31
+ maxAge: 60_000,
32
+ });
33
+
34
+ await cached({ id: 1 });
35
+ await cached({ id: 2 });
36
+
37
+ expect(loaderFn).toHaveBeenCalledTimes(2);
38
+ expect(getLoaderCacheStats().entries).toBe(2);
39
+ });
40
+
41
+ it("clearLoaderCache() empties the cache so the next call is a MISS", async () => {
42
+ const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
43
+ const cached = createCachedLoader("t/bust", loaderFn, {
44
+ policy: "stale-while-revalidate",
45
+ maxAge: 60_000,
46
+ });
47
+
48
+ await cached({ id: 1 }); // MISS
49
+ await cached({ id: 1 }); // HIT
50
+ expect(loaderFn).toHaveBeenCalledTimes(1);
51
+
52
+ clearLoaderCache();
53
+ expect(getLoaderCacheStats().entries).toBe(0);
54
+
55
+ await cached({ id: 1 }); // MISS again — cache was cleared
56
+ expect(loaderFn).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it("a purge during an in-flight loader does not repopulate the cleared entry", async () => {
60
+ // Loader we can resolve manually, to hold it "in flight" across the purge.
61
+ let release!: (v: { v: number }) => void;
62
+ const loaderFn = vi.fn(
63
+ () =>
64
+ new Promise<{ v: number }>((res) => {
65
+ release = res;
66
+ }),
67
+ );
68
+ const cached = createCachedLoader("t/race", loaderFn, {
69
+ policy: "stale-while-revalidate",
70
+ maxAge: 60_000,
71
+ });
72
+
73
+ const inflight = cached({ id: 1 }); // MISS — loader now pending
74
+ clearLoaderCache(); // purge lands while the loader is in flight
75
+ release({ v: 99 }); // loader resolves with pre-purge data
76
+ await expect(inflight).resolves.toEqual({ v: 99 }); // caller still gets its value
77
+
78
+ // ...but the raced result must NOT have been written back to the cache.
79
+ expect(getLoaderCacheStats().entries).toBe(0);
80
+ });
81
+
82
+ it("warns once per loader name when maxAge is very long", async () => {
83
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
84
+ const loaderFn = vi.fn(async () => ({}));
85
+
86
+ // > 10 min → warns
87
+ createCachedLoader("t/long-a", loaderFn, {
88
+ policy: "stale-while-revalidate",
89
+ maxAge: 3_600_000,
90
+ });
91
+ // same name again → deduped, no second warning
92
+ createCachedLoader("t/long-a", loaderFn, {
93
+ policy: "stale-while-revalidate",
94
+ maxAge: 3_600_000,
95
+ });
96
+ // short maxAge → no warning
97
+ createCachedLoader("t/short", loaderFn, {
98
+ policy: "stale-while-revalidate",
99
+ maxAge: 60_000,
100
+ });
101
+
102
+ const longWarnings = warn.mock.calls.filter(
103
+ (c) => typeof c[0] === "string" && c[0].includes("t/long-a"),
104
+ );
105
+ const shortWarnings = warn.mock.calls.filter(
106
+ (c) => typeof c[0] === "string" && c[0].includes("t/short"),
107
+ );
108
+ expect(longWarnings).toHaveLength(1);
109
+ expect(shortWarnings).toHaveLength(0);
110
+ });
111
+
112
+ it("no-store policy bypasses the cache entirely", async () => {
113
+ const loaderFn = vi.fn(async () => ({}));
114
+ const cached = createCachedLoader("t/nostore", loaderFn, { policy: "no-store" });
115
+
116
+ await cached({});
117
+ await cached({});
118
+
119
+ expect(loaderFn).toHaveBeenCalledTimes(2);
120
+ expect(getLoaderCacheStats().entries).toBe(0);
121
+ });
122
+ });
@@ -21,6 +21,32 @@ import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
21
21
  import { withInflightTimeout } from "./inflightTimeout";
22
22
  import { RequestContext } from "./requestContext";
23
23
 
24
+ // Build-time constant injected by `decoVitePlugin()` (see @decocms/tanstack's
25
+ // vite plugin) — the same commit-SHA/deploy token the edge Cache API uses as
26
+ // its `__v` cache-key version. Declared here with a `typeof` guard so it's
27
+ // inert where the define is not applied (Node tests, non-plugin builds). Same
28
+ // pattern already used in `../cms/blockSource.ts`.
29
+ declare const __DECO_BUILD_HASH__: string | undefined;
30
+
31
+ // Prefixes every in-memory loader cache key with the build hash, mirroring the
32
+ // edge cache's `__v`. This is defense-in-depth, NOT the primary invalidation
33
+ // lever: the cache Map is per-isolate, so a fresh isolate spun for a new deploy
34
+ // already starts empty, and a warm isolate keeps running its old bundle (hence
35
+ // its old BUILD) until it is recycled — the prefix cannot flush that. It only
36
+ // matters if this store ever becomes shared across builds. Gated on truthiness
37
+ // (empty string ⇒ unversioned) to match getBuildHash()/getDeploymentId() in
38
+ // workerEntry.ts / blockSource.ts.
39
+ const BUILD =
40
+ typeof __DECO_BUILD_HASH__ !== "undefined" && __DECO_BUILD_HASH__ ? __DECO_BUILD_HASH__ : "";
41
+
42
+ /**
43
+ * `maxAge` above this (10 min) is almost always a mistake for a commerce loader:
44
+ * upstream (catalog/price/stock) changes then take that long to propagate and a
45
+ * redeploy is the only fast lever. Warned once per loader name.
46
+ */
47
+ const LONG_MAXAGE_WARN = 600_000;
48
+ const warnedLongMaxAge = new Set<string>();
49
+
24
50
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
25
51
 
26
52
  export interface CachedLoaderOptions {
@@ -90,6 +116,13 @@ const MAX_CACHE_BYTES = resolveMaxBytes();
90
116
  const cache = new Map<string, CacheEntry>();
91
117
  let cacheBytes = 0;
92
118
 
119
+ // Bumped by clearLoaderCache(). A loader invocation captures this at entry and
120
+ // only writes its result back if the generation is unchanged when it settles —
121
+ // so a purge (e.g. POST /_cache/purge-loaders) that lands while a loader is
122
+ // in flight can't be silently undone by that in-flight loader repopulating a
123
+ // just-cleared entry with pre-purge data.
124
+ let cacheGeneration = 0;
125
+
93
126
  // Floor each entry at 512 bytes so we never accumulate unbounded zero-cost
94
127
  // entries when a loader legitimately returns `undefined` or an empty object
95
128
  // — `JSON.stringify(undefined) === undefined` and the eviction loop would
@@ -180,10 +213,22 @@ export function createCachedLoader<TProps, TResult>(
180
213
  const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
181
214
  const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
182
215
 
216
+ if (policy !== "no-store" && maxAge > LONG_MAXAGE_WARN && !warnedLongMaxAge.has(name)) {
217
+ warnedLongMaxAge.add(name);
218
+ console.warn(
219
+ `[cachedLoader] ${name}: maxAge=${Math.round(maxAge / 1000)}s is very long — ` +
220
+ `upstream changes take that long to propagate. Prefer a short window and use ` +
221
+ `POST /_cache/purge-loaders (or a redeploy) for immediate invalidation.`,
222
+ );
223
+ }
224
+
183
225
  if (policy === "no-store") return loaderFn;
184
226
 
185
227
  return async (props: TProps): Promise<TResult> => {
186
- const cacheKey = `${name}::${keyFn(props)}`;
228
+ const cacheKey = `${BUILD}::${name}::${keyFn(props)}`;
229
+ // Snapshot the cache generation; a purge during this invocation bumps it,
230
+ // and the deferred writes below skip repopulating a just-cleared entry.
231
+ const gen = cacheGeneration;
187
232
 
188
233
  const inflight = inflightRequests.get(cacheKey);
189
234
  if (inflight) {
@@ -248,6 +293,9 @@ export function createCachedLoader<TProps, TResult>(
248
293
  entry.refreshing = true;
249
294
  loaderFn(props)
250
295
  .then((result) => {
296
+ // Skip the write if a purge cleared the cache mid-refresh — otherwise
297
+ // we'd re-insert pre-purge data the purge was meant to drop.
298
+ if (gen !== cacheGeneration) return;
251
299
  setCacheEntry(cacheKey, {
252
300
  value: result,
253
301
  createdAt: Date.now(),
@@ -290,13 +338,17 @@ export function createCachedLoader<TProps, TResult>(
290
338
  )
291
339
  .then((result) => {
292
340
  recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
293
- setCacheEntry(cacheKey, {
294
- value: result,
295
- createdAt: Date.now(),
296
- refreshing: false,
297
- estimatedBytes: estimateBytes(result),
298
- });
299
- evictIfNeeded();
341
+ // Skip caching if a purge landed while this loader was in flight — still
342
+ // return the fresh value to the caller, just don't persist a raced entry.
343
+ if (gen === cacheGeneration) {
344
+ setCacheEntry(cacheKey, {
345
+ value: result,
346
+ createdAt: Date.now(),
347
+ refreshing: false,
348
+ estimatedBytes: estimateBytes(result),
349
+ });
350
+ evictIfNeeded();
351
+ }
300
352
  return result;
301
353
  })
302
354
  .catch((err) => {
@@ -380,7 +432,7 @@ export function createCachedLoaderFromModule<TProps, TResult>(
380
432
  // Explicit null → the loader declared this call uncacheable: run fresh.
381
433
  if (keyPart === null) return mod.default(props, req);
382
434
 
383
- const key = `${name}::${keyPart}`;
435
+ const key = `${BUILD}::${name}::${keyPart}`;
384
436
  const existing = moduleInflight.get(key) as Promise<TResult> | undefined;
385
437
  if (existing) return existing;
386
438
 
@@ -423,12 +475,21 @@ export function createLoaderEntry<TProps = any, TResult = any>(
423
475
  };
424
476
  }
425
477
 
426
- /** Clear all cached entries. Useful for decofile hot-reload. */
478
+ /**
479
+ * Clear all cached entries in THIS isolate. Used both by decofile hot-reload
480
+ * (`@decocms/blocks-admin`) and by the `POST /_cache/purge-loaders` route — an
481
+ * escape hatch to invalidate immediately (e.g. after an out-of-band Magento/
482
+ * catalog sync) without waiting out the TTL. Per-isolate: the route must be hit
483
+ * for every isolate; a redeploy replaces isolates wholesale. Bumps the cache
484
+ * generation so any loader in flight at purge time won't repopulate a cleared
485
+ * entry with pre-purge data.
486
+ */
427
487
  export function clearLoaderCache() {
428
488
  cache.clear();
429
489
  cacheBytes = 0;
430
490
  inflightRequests.clear();
431
491
  moduleInflight.clear();
492
+ cacheGeneration++;
432
493
  }
433
494
 
434
495
  /** Get cache stats for diagnostics. */