@decocms/tanstack 7.22.0 → 7.22.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.22.0",
3
+ "version": "7.22.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.22.0",
28
- "@decocms/blocks-admin": "7.22.0",
29
- "@decocms/blocks-cli": "7.22.0",
27
+ "@decocms/blocks": "7.22.2",
28
+ "@decocms/blocks-admin": "7.22.2",
29
+ "@decocms/blocks-cli": "7.22.2",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -1,7 +1,44 @@
1
1
  import { registerCacheableSections, registerSectionLoader } from "@decocms/blocks/cms";
2
2
  import type { ResolvedSection } from "@decocms/blocks/cms";
3
3
  import { describe, expect, it } from "vitest";
4
- import { runSectionLoadersWithSeo } from "./cmsRoute";
4
+ import {
5
+ parseLoadCmsHomePageInput,
6
+ parseLoadCmsPageInput,
7
+ runSectionLoadersWithSeo,
8
+ } from "./cmsRoute";
9
+
10
+ describe("parseLoadCmsPageInput (#292)", () => {
11
+ it("treats a bare string as the path with resolveGlobals defaulting to true (back-compat)", () => {
12
+ expect(parseLoadCmsPageInput("/produto/tenis")).toEqual({
13
+ path: "/produto/tenis",
14
+ resolveGlobals: true,
15
+ });
16
+ });
17
+
18
+ it("accepts { path, resolveGlobals } and preserves an explicit false", () => {
19
+ expect(parseLoadCmsPageInput({ path: "/produto/tenis", resolveGlobals: false })).toEqual({
20
+ path: "/produto/tenis",
21
+ resolveGlobals: false,
22
+ });
23
+ });
24
+
25
+ it("defaults resolveGlobals to true when omitted from the object form", () => {
26
+ expect(parseLoadCmsPageInput({ path: "/produto/tenis" })).toEqual({
27
+ path: "/produto/tenis",
28
+ resolveGlobals: true,
29
+ });
30
+ });
31
+ });
32
+
33
+ describe("parseLoadCmsHomePageInput (#292)", () => {
34
+ it("defaults resolveGlobals to true when called with no data (back-compat)", () => {
35
+ expect(parseLoadCmsHomePageInput(undefined)).toEqual({ resolveGlobals: true });
36
+ });
37
+
38
+ it("preserves an explicit resolveGlobals: false", () => {
39
+ expect(parseLoadCmsHomePageInput({ resolveGlobals: false })).toEqual({ resolveGlobals: false });
40
+ });
41
+ });
5
42
 
6
43
  /**
7
44
  * Regression guard for #355: body sections and the SEO section used to be
@@ -62,6 +62,7 @@ import { normalizeUrlsInObject } from "@decocms/blocks/sdk/normalizeUrls";
62
62
  import { type Device, detectDevice } from "@decocms/blocks/sdk/useDevice";
63
63
  import { derivePageUrl, isClientNavigation } from "./pageUrl";
64
64
  import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
65
+ import type { SiteGlobalRef } from "./withSiteGlobals";
65
66
 
66
67
  const isServer = typeof document === "undefined";
67
68
 
@@ -85,6 +86,12 @@ export function setSectionChunkMap(map: Record<string, string>): void {
85
86
  type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
86
87
  const pageInflight = new Map<string, Promise<PageResult>>();
87
88
 
89
+ /** Same shape as `resolveSiteGlobals()`'s empty result — used when `resolveGlobals: false`. */
90
+ const EMPTY_GLOBALS: { resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[] } = {
91
+ resolvedSections: [],
92
+ rawRefs: [],
93
+ };
94
+
88
95
  /** One year — the cohort is sticky; the `pct` fingerprint handles re-rolls. */
89
96
  const SEGMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
90
97
 
@@ -154,7 +161,7 @@ export async function runSectionLoadersWithSeo(
154
161
  };
155
162
  }
156
163
 
157
- async function loadCmsPageInternal(fullPath: string) {
164
+ async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
158
165
  const [basePath] = fullPath.split("?");
159
166
  // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
160
167
  // not the page being loaded — derivePageUrl rebuilds the real page URL so
@@ -188,7 +195,7 @@ async function loadCmsPageInternal(fullPath: string) {
188
195
  // empty client-bundled `blocks.gen.ts`.
189
196
  const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
190
197
  runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
191
- resolveSiteGlobals(),
198
+ resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
192
199
  ]);
193
200
 
194
201
  // Page sections take precedence over globals — dedupe drops any global
@@ -228,10 +235,20 @@ async function loadCmsPageInternal(fullPath: string) {
228
235
  };
229
236
  }
230
237
 
238
+ /** Accepted by `loadCmsPage` — a bare path (back-compat) or `{ path, resolveGlobals }`. */
239
+ export type LoadCmsPageInput = string | { path: string; resolveGlobals?: boolean };
240
+
241
+ /** @internal exported for tests */
242
+ export function parseLoadCmsPageInput(data: unknown): { path: string; resolveGlobals: boolean } {
243
+ if (typeof data === "string") return { path: data, resolveGlobals: true };
244
+ const obj = data as { path: string; resolveGlobals?: boolean };
245
+ return { path: obj.path, resolveGlobals: obj.resolveGlobals ?? true };
246
+ }
247
+
231
248
  export const loadCmsPage = createServerFn({ method: "GET" })
232
- .inputValidator((data: unknown) => data as string)
249
+ .inputValidator((data: unknown) => data as LoadCmsPageInput)
233
250
  .handler(async (ctx) => {
234
- const fullPath = ctx.data;
251
+ const { path: fullPath, resolveGlobals } = parseLoadCmsPageInput(ctx.data);
235
252
 
236
253
  // Use the full path (including query string) as the dedup key.
237
254
  // Using basePath only caused /s?q=a and /s?q=b to share one promise,
@@ -241,67 +258,84 @@ export const loadCmsPage = createServerFn({ method: "GET" })
241
258
  // for the same path may carry deferredSections; sharing that promise with a
242
259
  // concurrent SPA transition would smuggle those deferred sections in, causing
243
260
  // loadDeferredSection to run without the per-request commerce app context.
244
- // Use separate dedup buckets for SSR vs client-nav.
261
+ // Use separate dedup buckets for SSR vs client-nav. `resolveGlobals` is also
262
+ // part of the key — otherwise a `resolveGlobals: false` request could return
263
+ // (or share a promise with) a `resolveGlobals: true` response for the same path.
245
264
  const clientNav = isClientNavigation(fullPath, getRequestUrl());
246
- const inflightKey = clientNav ? `__nav:${fullPath}` : fullPath;
265
+ const inflightKey =
266
+ (clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
247
267
  const existing = pageInflight.get(inflightKey);
248
268
  if (existing) return existing;
249
269
 
250
- const promise = loadCmsPageInternal(fullPath).finally(() => pageInflight.delete(inflightKey));
270
+ const promise = loadCmsPageInternal(fullPath, resolveGlobals).finally(() =>
271
+ pageInflight.delete(inflightKey),
272
+ );
251
273
  pageInflight.set(inflightKey, promise);
252
274
  return promise;
253
275
  });
254
276
 
277
+ /** Accepted by `loadCmsHomePage` — optional so existing no-arg callers keep working. */
278
+ export type LoadCmsHomePageInput = { resolveGlobals?: boolean } | undefined;
279
+
280
+ /** @internal exported for tests */
281
+ export function parseLoadCmsHomePageInput(data: unknown): { resolveGlobals: boolean } {
282
+ const obj = (data ?? {}) as { resolveGlobals?: boolean };
283
+ return { resolveGlobals: obj.resolveGlobals ?? true };
284
+ }
285
+
255
286
  /**
256
287
  * Same as loadCmsPage but hardcoded to "/" path.
257
288
  * Avoids passing data through the server function for the homepage.
258
289
  */
259
- export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
260
- const request = getRequest();
261
- const ua = getRequestHeader("user-agent") ?? "";
262
- const serverUrl = getRequestUrl();
263
- const matcherCtx: MatcherContext = {
264
- userAgent: ua,
265
- url: serverUrl.toString(),
266
- path: "/",
267
- cookies: getCookies(),
268
- request,
269
- isClientNavigation: isClientNavigation("/", serverUrl),
270
- flags: [],
271
- };
272
- const page = await resolveDecoPage("/", matcherCtx);
273
- if (!page) return null;
274
- const flags = persistFlags(matcherCtx);
275
- const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
276
- runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
277
- resolveSiteGlobals(),
278
- ]);
279
-
280
- const mergedSections: ResolvedSection[] = [
281
- ...dedupeGlobals(globals.resolvedSections, enrichedSections),
282
- ...enrichedSections,
283
- ];
284
-
285
- const eagerKeys = mergedSections.map((s) => s.component);
286
- await preloadSectionComponents(eagerKeys);
287
-
288
- const device = detectDevice(ua);
289
- const seo = buildPageSeo(page.seoSection, enrichedSeoSection, mergedSections);
290
-
291
- const { seoSection: _seo, ...pageData } = page;
292
-
293
- return {
294
- ...pageData,
295
- resolvedSections: normalizeUrlsInObject(mergedSections),
296
- deferredSections: normalizeUrlsInObject(page.deferredSections),
297
- pagePath: "/",
298
- pageUrl: serverUrl.toString(),
299
- seo,
300
- device,
301
- flags,
302
- siteGlobals: { rawRefs: globals.rawRefs },
303
- };
304
- });
290
+ export const loadCmsHomePage = createServerFn({ method: "GET" })
291
+ .inputValidator((data: unknown) => data as LoadCmsHomePageInput)
292
+ .handler(async (ctx) => {
293
+ const { resolveGlobals } = parseLoadCmsHomePageInput(ctx.data);
294
+ const request = getRequest();
295
+ const ua = getRequestHeader("user-agent") ?? "";
296
+ const serverUrl = getRequestUrl();
297
+ const matcherCtx: MatcherContext = {
298
+ userAgent: ua,
299
+ url: serverUrl.toString(),
300
+ path: "/",
301
+ cookies: getCookies(),
302
+ request,
303
+ isClientNavigation: isClientNavigation("/", serverUrl),
304
+ flags: [],
305
+ };
306
+ const page = await resolveDecoPage("/", matcherCtx);
307
+ if (!page) return null;
308
+ const flags = persistFlags(matcherCtx);
309
+ const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
310
+ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
311
+ resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
312
+ ]);
313
+
314
+ const mergedSections: ResolvedSection[] = [
315
+ ...dedupeGlobals(globals.resolvedSections, enrichedSections),
316
+ ...enrichedSections,
317
+ ];
318
+
319
+ const eagerKeys = mergedSections.map((s) => s.component);
320
+ await preloadSectionComponents(eagerKeys);
321
+
322
+ const device = detectDevice(ua);
323
+ const seo = buildPageSeo(page.seoSection, enrichedSeoSection, mergedSections);
324
+
325
+ const { seoSection: _seo, ...pageData } = page;
326
+
327
+ return {
328
+ ...pageData,
329
+ resolvedSections: normalizeUrlsInObject(mergedSections),
330
+ deferredSections: normalizeUrlsInObject(page.deferredSections),
331
+ pagePath: "/",
332
+ pageUrl: serverUrl.toString(),
333
+ seo,
334
+ device,
335
+ flags,
336
+ siteGlobals: { rawRefs: globals.rawRefs },
337
+ };
338
+ });
305
339
 
306
340
  // ---------------------------------------------------------------------------
307
341
  // Deferred section loader — resolves + enriches a single section on demand
@@ -448,6 +482,16 @@ export interface CmsRouteOptions {
448
482
  * - `false`: Everything runs on client only.
449
483
  */
450
484
  ssr?: boolean | "data-only";
485
+ /**
486
+ * Resolve `site.theme + site.global + site.pageSections` (the CMS `Site`
487
+ * block) and merge them into every page's sections.
488
+ *
489
+ * Default: `true`. Set to `false` to restore pre-6.7.4 behavior for sites
490
+ * that don't use the `Site` block and don't want it silently activated
491
+ * (see #292) — this also skips the `Theme` section's `<style>` injection,
492
+ * so the site's own `:root` CSS fallbacks apply instead.
493
+ */
494
+ resolveGlobals?: boolean;
451
495
  }
452
496
 
453
497
  type CmsPageLoaderData = {
@@ -658,6 +702,7 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
658
702
  pendingMs = 200,
659
703
  pendingMinMs = 300,
660
704
  ssr: ssrMode,
705
+ resolveGlobals,
661
706
  } = options;
662
707
 
663
708
  const ignoreSet = new Set(ignoreSearchParams);
@@ -689,7 +734,9 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
689
734
  const searchStr = deps.search
690
735
  ? "?" + new URLSearchParams(deps.search as Record<string, string>).toString()
691
736
  : "";
692
- const page = await loadCmsPage({ data: basePath + searchStr });
737
+ const page = await loadCmsPage({
738
+ data: { path: basePath + searchStr, resolveGlobals },
739
+ });
693
740
  if (!page) return page;
694
741
 
695
742
  if (!isServer && page.resolvedSections) {
@@ -746,12 +793,21 @@ export function cmsHomeRouteConfig(options: {
746
793
  pendingMs?: number;
747
794
  /** Minimum display time (ms) for pending component. Default: 300. */
748
795
  pendingMinMs?: number;
796
+ /** See `CmsRouteOptions.resolveGlobals` — default `true`. */
797
+ resolveGlobals?: boolean;
749
798
  }) {
750
- const { defaultTitle, defaultDescription, siteName, pendingMs = 200, pendingMinMs = 300 } = options;
799
+ const {
800
+ defaultTitle,
801
+ defaultDescription,
802
+ siteName,
803
+ pendingMs = 200,
804
+ pendingMinMs = 300,
805
+ resolveGlobals,
806
+ } = options;
751
807
 
752
808
  return {
753
809
  loader: async () => {
754
- const page = await loadCmsHomePage();
810
+ const page = await loadCmsHomePage({ data: { resolveGlobals } });
755
811
  if (!page) return page;
756
812
 
757
813
  if (!isServer && page.resolvedSections) {
@@ -430,3 +430,53 @@ describe("POST /_cache/purge-loaders", () => {
430
430
  expect(res.status).toBe(404);
431
431
  });
432
432
  });
433
+
434
+ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
435
+ afterEach(() => {
436
+ __resetKvHydrationStateForTests();
437
+ setBlocks({});
438
+ });
439
+
440
+ const HTML_SERVER_ENTRY = {
441
+ fetch: async (_req: Request) =>
442
+ new Response("<html>hi</html>", {
443
+ status: 200,
444
+ headers: { "content-type": "text/html; charset=utf-8" },
445
+ }),
446
+ };
447
+
448
+ it("includes a custom segment field in X-Cache-Segment instead of dropping it", async () => {
449
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
450
+ observability: false,
451
+ buildSegment: () => ({ device: "desktop", regionId: "RJ", delivery: "store-42" }),
452
+ });
453
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
454
+ expect(res.headers.get("X-Cache-Segment")).toBe("desktop|r=RJ|delivery=store-42");
455
+ });
456
+
457
+ it("produces different X-Cache-Segment hashes for requests that only differ by a custom field", async () => {
458
+ let delivery = "store-1";
459
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
460
+ observability: false,
461
+ buildSegment: () => ({ device: "desktop", delivery }),
462
+ });
463
+
464
+ const first = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
465
+ const firstSegment = first.headers.get("X-Cache-Segment");
466
+
467
+ delivery = "store-2";
468
+ const second = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
469
+ const secondSegment = second.headers.get("X-Cache-Segment");
470
+
471
+ expect(firstSegment).not.toBe(secondSegment);
472
+ });
473
+
474
+ it("keeps the existing hash format for segments using only known fields (back-compat)", async () => {
475
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
476
+ observability: false,
477
+ buildSegment: () => ({ device: "desktop", salesChannel: "1", flags: ["b", "a"] }),
478
+ });
479
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
480
+ expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
481
+ });
482
+ });
@@ -172,6 +172,13 @@ export interface SegmentKey {
172
172
  regionId?: string;
173
173
  /** Sorted list of active A/B flag names for cache cohort splitting. */
174
174
  flags?: string[];
175
+ /**
176
+ * Custom cache dimensions beyond the known fields above (e.g. a
177
+ * store/CEP-based delivery selector). `hashSegment` serializes these
178
+ * automatically — unlike the known fields, there's no dedicated shorthand
179
+ * key, so don't reuse a name already declared above.
180
+ */
181
+ [customField: string]: string | boolean | string[] | undefined;
175
182
  }
176
183
 
177
184
  /**
@@ -983,12 +990,34 @@ export function createDecoWorkerEntry(
983
990
  return detectCacheProfile(target);
984
991
  }
985
992
 
993
+ const KNOWN_SEGMENT_FIELDS = new Set([
994
+ "device",
995
+ "loggedIn",
996
+ "salesChannel",
997
+ "regionId",
998
+ "flags",
999
+ ]);
1000
+
986
1001
  function hashSegment(seg: SegmentKey): string {
987
1002
  const parts: string[] = [seg.device];
988
1003
  if (seg.loggedIn) parts.push("auth");
989
1004
  if (seg.salesChannel) parts.push(`sc=${seg.salesChannel}`);
990
1005
  if (seg.regionId) parts.push(`r=${seg.regionId}`);
991
1006
  if (seg.flags?.length) parts.push(`f=${seg.flags.sort().join(",")}`);
1007
+ // Custom dimensions (anything beyond the known fields above) are
1008
+ // serialized generically so they aren't silently dropped from the
1009
+ // cache key — see #284.
1010
+ const customKeys = Object.keys(seg)
1011
+ .filter((k) => !KNOWN_SEGMENT_FIELDS.has(k) && seg[k] !== undefined)
1012
+ .sort();
1013
+ for (const k of customKeys) {
1014
+ const v = seg[k];
1015
+ if (v === "" || v === false || (Array.isArray(v) && v.length === 0)) {
1016
+ continue;
1017
+ }
1018
+ const serialized = Array.isArray(v) ? [...v].sort().join(",") : v === true ? "" : String(v);
1019
+ parts.push(serialized ? `${k}=${serialized}` : k);
1020
+ }
992
1021
  return parts.join("|");
993
1022
  }
994
1023