@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.
|
|
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.
|
|
31
|
-
"@decocms/blocks-admin": "7.78.
|
|
32
|
-
"@decocms/blocks-cli": "7.78.
|
|
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"
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
-
|
|
360
|
-
|
|
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
|
+
}
|