@decocms/blocks 7.14.0 → 7.15.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,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
registerLayoutSections,
|
|
4
|
+
registerSectionLoader,
|
|
4
5
|
registerSections,
|
|
5
6
|
resolveDecoPage,
|
|
7
|
+
runSectionLoaders,
|
|
6
8
|
setBlocks,
|
|
7
9
|
unregisterLayoutSections,
|
|
8
10
|
} from "./index";
|
|
@@ -103,6 +105,39 @@ describe("resolveDecoPage — layout section cache does not leak `.index` across
|
|
|
103
105
|
}
|
|
104
106
|
});
|
|
105
107
|
|
|
108
|
+
it("does not leak index across sequential requests when the result is already in the TTL cache", async () => {
|
|
109
|
+
// This covers the scenario missing from the concurrent test above:
|
|
110
|
+
// Page A is fully served first (result lands in layoutCache), then Page B
|
|
111
|
+
// arrives and hits the cache — it must get its own index, not Page A's.
|
|
112
|
+
const FOOTER_KEY = "site/sections/SeqCacheFooter.tsx";
|
|
113
|
+
|
|
114
|
+
registerLayoutSections([FOOTER_KEY]);
|
|
115
|
+
registerSectionLoader(FOOTER_KEY, async (props) => props);
|
|
116
|
+
|
|
117
|
+
const req = new Request("https://example.com/");
|
|
118
|
+
const makeSection = (index: number) => ({
|
|
119
|
+
component: FOOTER_KEY,
|
|
120
|
+
props: {},
|
|
121
|
+
key: `${FOOTER_KEY}-${index}`,
|
|
122
|
+
index,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// First request: populate the TTL cache with index 2.
|
|
127
|
+
const [sectionA] = await runSectionLoaders([makeSection(2)], req);
|
|
128
|
+
|
|
129
|
+
// Second request: hits the TTL cache — must get index 7, not 2.
|
|
130
|
+
const [sectionB] = await runSectionLoaders([makeSection(7)], req);
|
|
131
|
+
|
|
132
|
+
expect(sectionA?.index).toBe(2);
|
|
133
|
+
expect(sectionB?.index).toBe(7);
|
|
134
|
+
// Must be separate objects — the cached entry must never carry an index.
|
|
135
|
+
expect(sectionA).not.toBe(sectionB);
|
|
136
|
+
} finally {
|
|
137
|
+
unregisterLayoutSections([FOOTER_KEY]);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
106
141
|
it("mechanism check: mutating a shared object in place under concurrent stamping loses one side's index (isolated, framework-independent)", async () => {
|
|
107
142
|
// This isolates the exact bug mechanism from the diff that shipped in
|
|
108
143
|
// 6.12.1 and was reverted after the rollback, independent of the CMS
|
|
@@ -270,19 +270,26 @@ function resolveLayoutSection(
|
|
|
270
270
|
request: Request,
|
|
271
271
|
): Promise<ResolvedSection> {
|
|
272
272
|
const key = section.component;
|
|
273
|
+
const { index } = section;
|
|
274
|
+
|
|
275
|
+
// Re-apply the caller's page-specific index onto a fresh object so the
|
|
276
|
+
// shared cache entry never carries an index from a previous request.
|
|
277
|
+
const withIndex = (s: ResolvedSection): ResolvedSection =>
|
|
278
|
+
index !== undefined ? { ...s, index } : s;
|
|
273
279
|
|
|
274
280
|
const cached = getCachedLayout(key);
|
|
275
|
-
if (cached) return Promise.resolve(cached);
|
|
281
|
+
if (cached) return Promise.resolve(withIndex(cached));
|
|
276
282
|
|
|
277
283
|
const existing = layoutInflight.get(key);
|
|
278
|
-
if (existing) return existing;
|
|
284
|
+
if (existing) return existing.then(withIndex);
|
|
279
285
|
|
|
280
286
|
const promise = withInflightTimeout(
|
|
281
287
|
(async () => {
|
|
282
288
|
const enrichedProps = await loader(section.props as Record<string, unknown>, request);
|
|
283
|
-
const
|
|
289
|
+
const { index: _idx, ...sectionWithoutIndex } = section;
|
|
290
|
+
const enriched = { ...sectionWithoutIndex, props: enrichedProps };
|
|
284
291
|
setCachedLayout(key, enriched);
|
|
285
|
-
return enriched;
|
|
292
|
+
return withIndex(enriched);
|
|
286
293
|
})(),
|
|
287
294
|
`layoutSection ${key}`,
|
|
288
295
|
).finally(() => layoutInflight.delete(key));
|
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
|
+
});
|