@decocms/tanstack 7.78.1 → 7.78.2

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/tanstack",
3
- "version": "7.78.1",
3
+ "version": "7.78.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -27,9 +27,9 @@
27
27
  "lint:unused": "knip"
28
28
  },
29
29
  "dependencies": {
30
- "@decocms/blocks": "7.78.1",
31
- "@decocms/blocks-admin": "7.78.1",
32
- "@decocms/blocks-cli": "7.78.1",
30
+ "@decocms/blocks": "7.78.2",
31
+ "@decocms/blocks-admin": "7.78.2",
32
+ "@decocms/blocks-cli": "7.78.2",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -69,6 +69,7 @@ import {
69
69
  } from "@tanstack/react-start/server";
70
70
  import { createElement } from "react";
71
71
  import { derivePageUrl, isClientNavigation } from "./pageUrl";
72
+ import { createRequestSingleFlight } from "./requestSingleFlight";
72
73
  import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
73
74
  import type { SiteGlobalRef } from "./withSiteGlobals";
74
75
 
@@ -92,7 +93,7 @@ export function setSectionChunkMap(map: Record<string, string>): void {
92
93
  // ---------------------------------------------------------------------------
93
94
 
94
95
  type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
95
- const pageInflight = new Map<string, Promise<PageResult>>();
96
+ const loadPageForRequest = createRequestSingleFlight<PageResult>();
96
97
 
97
98
  /** Same shape as `resolveSiteGlobals()`'s empty result — used when `resolveGlobals: false`. */
98
99
  const EMPTY_GLOBALS: { resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[] } = {
@@ -337,33 +338,15 @@ export const loadCmsPage = createServerFn({ method: "GET" })
337
338
  .handler(async (ctx) => {
338
339
  const { path: fullPath, resolveGlobals } = parseLoadCmsPageInput(ctx.data);
339
340
 
340
- // Use the full path (including query string) as the dedup key.
341
- // Using basePath only caused /s?q=a and /s?q=b to share one promise,
342
- // returning wrong/empty results for search and filtered PLPs.
343
- //
344
- // SSR and client-nav produce the same eager/deferred split now, so the
345
- // original reason for the `__nav:` bucket — a client-nav request must never
346
- // inherit an SSR response's deferredSections (#277) — no longer applies. It
347
- // is kept anyway, because this map is module-global and the payload it
348
- // shares carries `pageUrl`, `flags`, and `device` derived from whichever
349
- // request won the race. That cross-request bleed is pre-existing and wider
350
- // than this bucket (two concurrent same-path requests already collide on
351
- // cookies/UA/geo), but SSR and client-nav are exactly the pair whose
352
- // `derivePageUrl` inputs differ most, so collapsing them would widen a
353
- // known hole for no gain. `resolveGlobals` is part of the key for the same
354
- // reason: a `resolveGlobals: false` request must not share a promise with a
355
- // `resolveGlobals: true` one.
341
+ // Whole-page results contain request-specific cookies, flags, device and URL.
342
+ // A module-global URL key also lets one stalled request block later visitors.
343
+ // Dedup only within this request; keep query and globals options distinct.
356
344
  const clientNav = isClientNavigation(fullPath, getRequestUrl());
357
345
  const inflightKey =
358
346
  (clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
359
- const existing = pageInflight.get(inflightKey);
360
- if (existing) return existing;
361
-
362
- const promise = loadCmsPageInternal(fullPath, resolveGlobals).finally(() =>
363
- pageInflight.delete(inflightKey),
347
+ return loadPageForRequest(getRequest(), inflightKey, () =>
348
+ loadCmsPageInternal(fullPath, resolveGlobals),
364
349
  );
365
- pageInflight.set(inflightKey, promise);
366
- return promise;
367
350
  });
368
351
 
369
352
  /** Accepted by `loadCmsHomePage` — optional so existing no-arg callers keep working. */
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createRequestSingleFlight } from "./requestSingleFlight";
3
+
4
+ describe("request-scoped page single-flight", () => {
5
+ it("lets a healthy visitor complete while an earlier visitor's page is stuck", async () => {
6
+ const load = createRequestSingleFlight<string>();
7
+ const path = "/granado/sabonetes/sabonete-barra";
8
+ let release!: (value: string) => void;
9
+ const first = load(
10
+ new Request(`https://shop.test${path}`),
11
+ path,
12
+ () =>
13
+ new Promise<string>((resolve) => {
14
+ release = resolve;
15
+ }),
16
+ );
17
+ const healthy = load(
18
+ new Request(`https://shop.test${path}`),
19
+ path,
20
+ async () => "healthy visitor",
21
+ );
22
+ try {
23
+ expect(
24
+ await Promise.race([
25
+ healthy,
26
+ new Promise((resolve) => setTimeout(() => resolve("blocked"), 100)),
27
+ ]),
28
+ ).toBe("healthy visitor");
29
+ } finally {
30
+ release("earlier visitor");
31
+ await first;
32
+ }
33
+ });
34
+
35
+ it("deduplicates concurrent callers within one request and removes completed work", async () => {
36
+ const load = createRequestSingleFlight<string>();
37
+ const request = new Request("https://shop.test/category");
38
+ const run = vi.fn(async () => "page");
39
+ const first = load(request, "/category", run);
40
+ expect(load(request, "/category", run)).toBe(first);
41
+ await first;
42
+ await load(request, "/category", run);
43
+ expect(run).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("keeps page options separate and allows retry after a failure", async () => {
47
+ const load = createRequestSingleFlight<string>();
48
+ const request = new Request("https://shop.test/category");
49
+ await expect(
50
+ load(request, "/category", async () => {
51
+ throw new Error("failed");
52
+ }),
53
+ ).rejects.toThrow("failed");
54
+ expect(await load(request, "/category", async () => "retry")).toBe("retry");
55
+ const results = await Promise.all([
56
+ load(request, "/category?p=1", async () => "first page"),
57
+ load(request, "/category?p=2", async () => "second page"),
58
+ load(request, "/category?p=1|noGlobals", async () => "without globals"),
59
+ ]);
60
+ expect(results).toEqual(["first page", "second page", "without globals"]);
61
+ });
62
+ });
@@ -0,0 +1,17 @@
1
+ /** Share pending work only among callers belonging to the same HTTP request. */
2
+ export function createRequestSingleFlight<T>() {
3
+ const requests = new WeakMap<Request, Map<string, Promise<T>>>();
4
+
5
+ return (request: Request, key: string, run: () => Promise<T>): Promise<T> => {
6
+ const pending = requests.get(request) ?? new Map<string, Promise<T>>();
7
+ requests.set(request, pending);
8
+ const existing = pending.get(key);
9
+ if (existing) return existing;
10
+
11
+ const promise = Promise.resolve()
12
+ .then(run)
13
+ .finally(() => pending.delete(key));
14
+ pending.set(key, promise);
15
+ return promise;
16
+ };
17
+ }