@decocms/tanstack 8.0.0 → 8.1.0-next.0

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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
package/src/index.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  export {
2
+ DecoPageRenderer,
3
+ DecoRootLayout,
4
+ DraftPreviewIndicator,
5
+ NavigationProgress,
6
+ PreviewProviders,
7
+ SectionList,
8
+ SectionRenderer,
9
+ StableOutlet,
10
+ } from "./hooks";
11
+ export {
12
+ CmsPage,
2
13
  cmsHomeRouteConfig,
3
14
  cmsRouteConfig,
4
- CmsPage,
5
15
  decoInvokeRouteConfig,
6
16
  decoMetaRouteConfig,
7
17
  decoRenderRouteConfig,
@@ -11,23 +21,23 @@ export {
11
21
  NotFoundPage,
12
22
  withSiteGlobals,
13
23
  } from "./routes";
14
- export {
15
- DecoPageRenderer,
16
- DecoRootLayout,
17
- NavigationProgress,
18
- PreviewProviders,
19
- SectionList,
20
- SectionRenderer,
21
- StableOutlet,
22
- } from "./hooks";
23
- export { createDecoWorkerEntry } from "./sdk/workerEntry";
24
- export { setupTanstackFastDeploy } from "./setupFastDeploy";
24
+ export type { CreateDecoRouterOptions } from "./sdk/router";
25
25
  export {
26
26
  createDecoRouter,
27
27
  decoParseSearch,
28
28
  decoStringifySearch,
29
29
  } from "./sdk/router";
30
- export type { CreateDecoRouterOptions } from "./sdk/router";
30
+ export type {
31
+ SpeculationAction,
32
+ SpeculationEagerness,
33
+ SpeculationRulesConfig,
34
+ } from "./sdk/speculationRules";
35
+ export {
36
+ buildSpeculationRules,
37
+ DEFAULT_EXCLUDED_HREF_MATCHES,
38
+ } from "./sdk/speculationRules";
39
+ export { createDecoWorkerEntry } from "./sdk/workerEntry";
40
+ export { setupTanstackFastDeploy } from "./setupFastDeploy";
31
41
  // createInvokeFn is intentionally NOT re-exported from this root barrel.
32
42
  // Its body contains a `createServerFn(...).handler(...)` call that is not a
33
43
  // top-level variable declarator (it's returned from a factory function) --
@@ -42,3 +52,10 @@ export type { CreateDecoRouterOptions } from "./sdk/router";
42
52
  // parses (never imports) to emit real top-level createServerFn declarations
43
53
  // into each site's own src/server/invoke.gen.ts. Import it from the
44
54
  // dedicated "@decocms/tanstack/sdk/createInvoke" subpath instead.
55
+ // decoServerFnFetch is intentionally NOT re-exported from this root barrel
56
+ // either. A site wires it in `src/start.ts`, which is part of the CLIENT
57
+ // bundle; importing it from here would drag `createDecoWorkerEntry` (and the
58
+ // whole server graph behind it) into that bundle. Import it from the dedicated
59
+ // "@decocms/tanstack/sdk/serverFnFetch" subpath instead.
60
+ // Next major (v8): the binding's documented surface. See /next/api-reference#loaders.
61
+ export { kvLoader } from "./v8/kvLoader";
@@ -1,9 +1,17 @@
1
- import { registerCacheableSections, registerSectionLoader } from "@decocms/blocks/cms";
1
+ import {
2
+ registerCacheableSections,
3
+ registerSectionLoader,
4
+ runSectionLoaders,
5
+ } from "@decocms/blocks/cms";
2
6
  import type { ResolvedSection } from "@decocms/blocks/cms";
3
7
  import { describe, expect, it } from "vitest";
4
8
  import {
9
+ CmsPagePendingFallback,
10
+ cmsHomeRouteConfig,
11
+ cmsRouteConfig,
5
12
  parseLoadCmsHomePageInput,
6
13
  parseLoadCmsPageInput,
14
+ enrichGlobals,
7
15
  runSectionLoadersWithSeo,
8
16
  } from "./cmsRoute";
9
17
 
@@ -98,3 +106,162 @@ describe("runSectionLoadersWithSeo (#355)", () => {
98
106
  expect(enrichedSeoSection).toBeNull();
99
107
  });
100
108
  });
109
+
110
+ /**
111
+ * Regression guard: `site.global` / `site.pageSections` entries never went
112
+ * through `runSectionLoaders`. `resolveSiteGlobals()` resolves only their
113
+ * *props* (and SWR-caches that across visitors for 5 minutes), so a global
114
+ * section's `loader` export simply never ran — the component rendered with
115
+ * raw CMS props and every loader-derived field was `undefined`.
116
+ *
117
+ * The visible symptom on a real storefront: an analytics global that has to
118
+ * emit its event inline, in document order (before the page-view global's
119
+ * script), could not do so at all. Rewriting it as a client-side `useQuery`
120
+ * moved the event to after hydration, which reorders the dataLayer.
121
+ */
122
+ describe("enrichGlobals — site.global loaders run per request", () => {
123
+ it("runs a global section's loader with the real request", async () => {
124
+ const component = `test/sections/GlobalLoader-${Date.now()}.tsx`;
125
+ const seen: string[] = [];
126
+ registerSectionLoader(component, async (props, req) => {
127
+ seen.push(req.headers.get("cookie") ?? "");
128
+ return { ...props, loaded: true };
129
+ });
130
+
131
+ const global: ResolvedSection = { component, props: {}, key: component, index: 0 };
132
+ const request = new Request("https://store.com/", { headers: { cookie: "sid=abc" } });
133
+
134
+ const result = await enrichGlobals([global], [], request);
135
+
136
+ expect((result[0].props as any).loaded).toBe(true);
137
+ expect(seen).toEqual(["sid=abc"]);
138
+ });
139
+
140
+ it("dedupes before running loaders — a page-overridden global never pays for its loader", async () => {
141
+ const component = `test/sections/GlobalDedupe-${Date.now()}.tsx`;
142
+ let calls = 0;
143
+ registerSectionLoader(component, async (props) => {
144
+ calls++;
145
+ return props;
146
+ });
147
+
148
+ const global: ResolvedSection = { component, props: {}, key: component, index: 0 };
149
+ const pageSection: ResolvedSection = { component, props: {}, key: `${component}-page`, index: 1 };
150
+
151
+ const result = await enrichGlobals([global], [pageSection], new Request("https://store.com/"));
152
+
153
+ expect(result).toEqual([]);
154
+ expect(calls).toBe(0);
155
+ });
156
+
157
+ it("preserves global order and passes through globals with no registered loader", async () => {
158
+ const withLoader = `test/sections/GlobalA-${Date.now()}.tsx`;
159
+ const withoutLoader = `test/sections/GlobalB-${Date.now()}.tsx`;
160
+ registerSectionLoader(withLoader, async (props) => ({ ...props, loaded: true }));
161
+
162
+ const globals: ResolvedSection[] = [
163
+ { component: withLoader, props: {}, key: withLoader, index: 0 },
164
+ { component: withoutLoader, props: { raw: 1 }, key: withoutLoader, index: 1 },
165
+ ];
166
+
167
+ const result = await enrichGlobals(globals, [], new Request("https://store.com/"));
168
+
169
+ expect(result.map((s) => s.component)).toEqual([withLoader, withoutLoader]);
170
+ expect((result[0].props as any).loaded).toBe(true);
171
+ expect((result[1].props as any).raw).toBe(1);
172
+ });
173
+
174
+ it("is a no-op when every global is deduped away", async () => {
175
+ expect(await enrichGlobals([], [], new Request("https://store.com/"))).toEqual([]);
176
+ });
177
+
178
+ /**
179
+ * Both call sites dedupe against the page's RAW sections so the globals'
180
+ * loaders can run concurrently with the page's instead of behind them. That
181
+ * is only sound while `runSectionLoaders` leaves `section.component`
182
+ * untouched — `dedupeGlobals` reads nothing else. If a future change makes a
183
+ * loader rewrite `component` (or add/drop sections), the two dedupe targets
184
+ * stop agreeing and an overridden global would render twice. Lock it here.
185
+ */
186
+ it("dedupes identically against raw and loader-enriched page sections", async () => {
187
+ const shared = `test/sections/GlobalInvariant-${Date.now()}.tsx`;
188
+ const globalOnly = `test/sections/GlobalOnly-${Date.now()}.tsx`;
189
+ registerSectionLoader(shared, async (props) => ({ ...props, loaded: true }));
190
+ registerSectionLoader(globalOnly, async (props) => ({ ...props, loaded: true }));
191
+
192
+ const globals: ResolvedSection[] = [
193
+ { component: globalOnly, props: {}, key: globalOnly, index: 0 },
194
+ { component: shared, props: {}, key: shared, index: 1 },
195
+ ];
196
+ const rawPageSections: ResolvedSection[] = [
197
+ { component: shared, props: { sku: "1" }, key: `${shared}-page`, index: 0 },
198
+ ];
199
+
200
+ const request = new Request("https://store.com/");
201
+ const enrichedPageSections = await runSectionLoaders(rawPageSections, request);
202
+
203
+ // The loader must not have touched `component` — the whole premise.
204
+ expect(enrichedPageSections.map((s) => s.component)).toEqual(
205
+ rawPageSections.map((s) => s.component),
206
+ );
207
+
208
+ const viaRaw = await enrichGlobals(globals, rawPageSections, request);
209
+ const viaEnriched = await enrichGlobals(globals, enrichedPageSections, request);
210
+
211
+ expect(viaRaw.map((s) => s.component)).toEqual([globalOnly]);
212
+ expect(viaRaw.map((s) => s.component)).toEqual(viaEnriched.map((s) => s.component));
213
+ });
214
+ });
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Pending UI defaults
218
+ // ---------------------------------------------------------------------------
219
+ //
220
+ // There is deliberately NO default pendingComponent. Setting one turns every
221
+ // navigation slower than `pendingMs` (200ms) into a page → skeleton → page swap
222
+ // held for at least `pendingMinMs` (300ms). Deferral pulls most navigations into
223
+ // the 200–600ms band, i.e. exactly the window where that swap costs more than it
224
+ // buys, and this is a catch-all route, so one skeleton shape would have to serve
225
+ // PDP, PLP, search, and institutional pages alike. Keeping the previous page on
226
+ // screen until the new one commits is the better default; a site that wants a
227
+ // skeleton opts in per-shape.
228
+ //
229
+ // `pendingMs`/`pendingMinMs` DO keep defaults — they are inert without a
230
+ // pendingComponent, and they also govern a site-provided one.
231
+
232
+ describe("cmsRouteConfig / cmsHomeRouteConfig — pending UI defaults", () => {
233
+ const base = { siteName: "Loja", defaultTitle: "Loja" };
234
+
235
+ it("ships NO default pendingComponent — the previous page stays until commit", () => {
236
+ expect("pendingComponent" in cmsRouteConfig(base)).toBe(false);
237
+ expect("pendingComponent" in cmsHomeRouteConfig({ defaultTitle: "Loja" })).toBe(false);
238
+ });
239
+
240
+ it("honors a site-provided pendingComponent on both route configs", () => {
241
+ const custom = () => null;
242
+ expect(cmsRouteConfig({ ...base, pendingComponent: custom }).pendingComponent).toBe(custom);
243
+ expect(
244
+ cmsHomeRouteConfig({ defaultTitle: "Loja", pendingComponent: custom }).pendingComponent,
245
+ ).toBe(custom);
246
+ });
247
+
248
+ it("treats an explicit null the same as omitting it", () => {
249
+ expect("pendingComponent" in cmsRouteConfig({ ...base, pendingComponent: null })).toBe(false);
250
+ expect(
251
+ "pendingComponent" in cmsHomeRouteConfig({ defaultTitle: "Loja", pendingComponent: null }),
252
+ ).toBe(false);
253
+ });
254
+
255
+ it("still exports CmsPagePendingFallback as an opt-in starting point", () => {
256
+ expect(typeof CmsPagePendingFallback).toBe("function");
257
+ expect(
258
+ cmsRouteConfig({ ...base, pendingComponent: CmsPagePendingFallback }).pendingComponent,
259
+ ).toBe(CmsPagePendingFallback);
260
+ });
261
+
262
+ it("keeps the documented pendingMs / pendingMinMs defaults", () => {
263
+ const cfg = cmsRouteConfig(base);
264
+ expect(cfg.pendingMs).toBe(200);
265
+ expect(cfg.pendingMinMs).toBe(300);
266
+ });
267
+ });
@@ -49,6 +49,7 @@ import {
49
49
  detectCacheProfile,
50
50
  routeCacheDefaults,
51
51
  } from "@decocms/blocks/sdk/cacheHeaders";
52
+ import { takeExperimentAssignments } from "@decocms/blocks/sdk/experiments";
52
53
  import {
53
54
  parseSegmentCookie,
54
55
  SEGMENT_COOKIE,
@@ -116,9 +117,16 @@ const SEGMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
116
117
  *
117
118
  * `encode: (v) => v` keeps the raw base64 intact — @decocms/apps
118
119
  * OneDollarStats reads the cookie with `atob()` directly.
120
+ *
121
+ * Experiment assignments (`sdk/experiments.ts`) merge in through the same
122
+ * writer rather than setting their own cookie — one `deco_segment`, one
123
+ * Set-Cookie. That is why this runs AFTER the section loaders: CMS matchers
124
+ * decide during page resolution, but an experiment is resolved inside a
125
+ * loader, so persisting before the loaders ran would drop the assignment and
126
+ * the visitor would re-roll on every request.
119
127
  */
120
- function persistFlags(matcherCtx: MatcherContext): StoredFlag[] {
121
- const recorded = matcherCtx.flags ?? [];
128
+ function persistFlags(matcherCtx: MatcherContext, experiments: StoredFlag[] = []): StoredFlag[] {
129
+ const recorded = [...(matcherCtx.flags ?? []), ...experiments];
122
130
  if (!recorded.length) return [];
123
131
 
124
132
  const raw = matcherCtx.cookies?.[SEGMENT_COOKIE];
@@ -168,6 +176,46 @@ export async function runSectionLoadersWithSeo(
168
176
  };
169
177
  }
170
178
 
179
+ /**
180
+ * Dedupe `site.global` / `site.pageSections` against the page's own sections,
181
+ * then run the survivors' loaders against the real per-request Request.
182
+ *
183
+ * `resolveSiteGlobals()` only resolves a global's *props* — and it SWR-caches
184
+ * that result for 5 minutes across every visitor. Until now nothing ever fed
185
+ * globals through `runSectionLoaders`, so a global section's `loader` export
186
+ * simply never ran: the component rendered with the raw CMS props and every
187
+ * loader-derived field arrived `undefined`. Sites worked around it by moving
188
+ * the work client-side (a `useQuery` over `/deco/invoke`), which changes both
189
+ * the timing and the SSR output — an analytics global that has to emit its
190
+ * event inline, in document order, cannot be rewritten that way at all.
191
+ *
192
+ * Loaders run per request and are NOT cached here; only the prop resolution
193
+ * behind `resolveSiteGlobals()` keeps its 5-minute cache. Dedupe runs first so
194
+ * a global that the page overrides never pays for its loader.
195
+ *
196
+ * Section-level caching still applies: a global registered via
197
+ * `registerLayoutSections` / `registerCacheableSections` goes through the same
198
+ * cache tiers as any other section inside `runSectionLoaders`.
199
+ *
200
+ * `pageSections` may be the page's RAW sections — it does not have to be the
201
+ * loader-enriched ones. `dedupeGlobals` reads only `section.component`, and no
202
+ * branch of `runSingleSectionLoaderImpl` ever rewrites `component` (every path
203
+ * returns `section` or `{ ...section, props }`, 1:1, never adding or dropping
204
+ * entries). Passing the raw list is what lets this run CONCURRENTLY with the
205
+ * page's own loaders instead of behind them.
206
+ *
207
+ * @internal exported for tests
208
+ */
209
+ export async function enrichGlobals(
210
+ globalSections: ResolvedSection[],
211
+ pageSections: ResolvedSection[],
212
+ request: Request,
213
+ ): Promise<ResolvedSection[]> {
214
+ const kept = dedupeGlobals(globalSections, pageSections);
215
+ if (kept.length === 0) return kept;
216
+ return runSectionLoaders(kept, request);
217
+ }
218
+
171
219
  async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
172
220
  const [basePath] = fullPath.split("?");
173
221
  // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
@@ -188,7 +236,6 @@ async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
188
236
  };
189
237
  const page = await resolveDecoPage(basePath, matcherCtx);
190
238
  if (!page) return null;
191
- const flags = persistFlags(matcherCtx);
192
239
 
193
240
  const request = new Request(urlWithSearch, {
194
241
  headers: originRequest.headers,
@@ -200,17 +247,33 @@ async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
200
247
  // identical for SSR (F5) and SPA (<Link>) navigations — see #233 for the
201
248
  // previous SPA-breakage when `withSiteGlobals` ran client-side and saw an
202
249
  // empty client-bundled `blocks.gen.ts`.
203
- const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
204
- runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
205
- resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
206
- ]);
207
-
208
- // Page sections take precedence over globals — dedupe drops any global
209
- // whose component is already rendered by the page.
210
- const mergedSections: ResolvedSection[] = [
211
- ...dedupeGlobals(globals.resolvedSections, enrichedSections),
212
- ...enrichedSections,
213
- ];
250
+ const [{ enrichedSections, enrichedSeoSection }, { globals, enrichedGlobals }] =
251
+ await Promise.all([
252
+ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
253
+ (async () => {
254
+ const globals = resolveGlobals ? await resolveSiteGlobals(matcherCtx) : EMPTY_GLOBALS;
255
+ return {
256
+ globals,
257
+ // Deduped against the page's RAW sections on purpose — see enrichGlobals.
258
+ // Waiting for `enrichedSections` would put the globals' loaders behind
259
+ // the page's on the TTFB critical path for no benefit.
260
+ enrichedGlobals: await enrichGlobals(
261
+ globals.resolvedSections,
262
+ page.resolvedSections,
263
+ request,
264
+ ),
265
+ };
266
+ })(),
267
+ ]);
268
+
269
+ // After the loaders — a loader-resolved experiment assignment must be in the
270
+ // cookie this response sets. See persistFlags.
271
+ const flags = persistFlags(matcherCtx, takeExperimentAssignments());
272
+
273
+ // Page sections take precedence over globals — dedupe drops any global whose
274
+ // component is already rendered by the page. The survivors ran their own
275
+ // loaders above, concurrently with the page's (see enrichGlobals).
276
+ const mergedSections: ResolvedSection[] = [...enrichedGlobals, ...enrichedSections];
214
277
 
215
278
  // Pre-import eager section modules so their default exports are cached
216
279
  // in resolvedComponents. This ensures SSR renders with direct component
@@ -278,13 +341,18 @@ export const loadCmsPage = createServerFn({ method: "GET" })
278
341
  // Using basePath only caused /s?q=a and /s?q=b to share one promise,
279
342
  // returning wrong/empty results for search and filtered PLPs.
280
343
  //
281
- // Client-nav resolves all sections eagerly (#277). An in-flight SSR response
282
- // for the same path may carry deferredSections; sharing that promise with a
283
- // concurrent SPA transition would smuggle those deferred sections in, causing
284
- // loadDeferredSection to run without the per-request commerce app context.
285
- // Use separate dedup buckets for SSR vs client-nav. `resolveGlobals` is also
286
- // part of the key — otherwise a `resolveGlobals: false` request could return
287
- // (or share a promise with) a `resolveGlobals: true` response for the same path.
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.
288
356
  const clientNav = isClientNavigation(fullPath, getRequestUrl());
289
357
  const inflightKey =
290
358
  (clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
@@ -329,16 +397,24 @@ export const loadCmsHomePage = createServerFn({ method: "GET" })
329
397
  };
330
398
  const page = await resolveDecoPage("/", matcherCtx);
331
399
  if (!page) return null;
332
- const flags = persistFlags(matcherCtx);
333
- const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
334
- runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
335
- resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
336
- ]);
337
-
338
- const mergedSections: ResolvedSection[] = [
339
- ...dedupeGlobals(globals.resolvedSections, enrichedSections),
340
- ...enrichedSections,
341
- ];
400
+ const [{ enrichedSections, enrichedSeoSection }, { globals, enrichedGlobals }] =
401
+ await Promise.all([
402
+ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
403
+ (async () => {
404
+ const globals = resolveGlobals ? await resolveSiteGlobals(matcherCtx) : EMPTY_GLOBALS;
405
+ return {
406
+ globals,
407
+ enrichedGlobals: await enrichGlobals(
408
+ globals.resolvedSections,
409
+ page.resolvedSections,
410
+ request,
411
+ ),
412
+ };
413
+ })(),
414
+ ]);
415
+ const flags = persistFlags(matcherCtx, takeExperimentAssignments());
416
+
417
+ const mergedSections: ResolvedSection[] = [...enrichedGlobals, ...enrichedSections];
342
418
 
343
419
  const eagerKeys = mergedSections.map((s) => s.component);
344
420
  await preloadSectionComponents(eagerKeys);
@@ -381,9 +457,16 @@ export const loadCmsHomePage = createServerFn({ method: "GET" })
381
457
  // ---------------------------------------------------------------------------
382
458
 
383
459
  /**
384
- * @deprecated Prefer TanStack native streaming via `deferredPromises` in the
385
- * route loader. This POST server function is kept for backward compatibility
386
- * and as a fallback for SPA navigations.
460
+ * Resolves + enriches a single deferred section on demand, server-side.
461
+ *
462
+ * This is the deferred-resolution path for BOTH the initial SSR document and
463
+ * client (SPA) navigations — `deferredPromises`/TanStack native streaming is
464
+ * SSR-only, because promises cannot cross the server-fn JSON boundary. It
465
+ * rebuilds `MatcherContext` from the real incoming request (url/path/cookies/
466
+ * request), so the second hop sees the same per-request state the first one did
467
+ * (the `deco_segment` cookie set by the page response is already present, so
468
+ * sticky A/B decisions stay consistent — `flags` is intentionally left unset
469
+ * here: this response must not re-roll or re-persist the cohort cookie).
387
470
  */
388
471
  export const loadDeferredSection = createServerFn({ method: "POST" })
389
472
  .inputValidator(
@@ -547,8 +630,21 @@ export interface CmsRouteOptions {
547
630
  * Defaults to `["skuId"]` — variant selection is client-side only.
548
631
  */
549
632
  ignoreSearchParams?: string[];
550
- /** Custom pending component shown during SPA navigation. */
551
- pendingComponent?: () => any;
633
+ /**
634
+ * Pending component shown during SPA navigation while the route loader runs.
635
+ *
636
+ * NO DEFAULT, deliberately. With none set, TanStack keeps the previous page on
637
+ * screen until the new one commits — which at the sub-second latencies this
638
+ * route now sees is usually the better feel. Setting one buys feedback on a
639
+ * slow navigation at the cost of a page → skeleton → page swap on every
640
+ * navigation slower than {@link pendingMs} (held for at least
641
+ * {@link pendingMinMs}), and this is a *catch-all* route, so one skeleton
642
+ * shape has to serve PDP, PLP, search, and institutional pages alike.
643
+ * {@link CmsPagePendingFallback} is exported as a starting point; prefer a
644
+ * per-shape skeleton, or `NavigationProgress` (`@decocms/tanstack`) for
645
+ * feedback that does not replace the page at all.
646
+ */
647
+ pendingComponent?: (() => any) | null;
552
648
  /**
553
649
  * Custom error boundary rendered when the loader / page resolution throws.
554
650
  * Defaults to {@link CmsPageErrorFallback} (branded "instability" page with a
@@ -560,12 +656,23 @@ export interface CmsRouteOptions {
560
656
  * Delay (ms) before showing the pending component during SPA navigation.
561
657
  * If the loader resolves before this threshold, no pending UI is shown.
562
658
  * Prevents skeleton flash on fast cache-hit navigations. Default: 200.
659
+ * Inert unless {@link pendingComponent} is set.
660
+ *
661
+ * This is a *catch-all* route, so the same value covers a sub-100ms PLP facet
662
+ * toggle and a cold PDP. Raising it only DELAYS the skeleton on the slow
663
+ * navigation (a 1s `pendingMs` still shows it on a 2s nav) — it does not
664
+ * suppress it. Use {@link pendingMinMs} for the flicker case where the loader
665
+ * lands just after the threshold.
563
666
  */
564
667
  pendingMs?: number;
565
668
  /**
566
669
  * Minimum display time (ms) for the pending component once shown.
567
670
  * Prevents jarring flash when data arrives shortly after the threshold.
568
- * Default: 300.
671
+ * Default: 300. Inert unless {@link pendingComponent} is set.
672
+ *
673
+ * Independent of {@link pendingMs}, and applied after it: worst-case a
674
+ * navigation is held for `pendingMs + pendingMinMs` of visible skeleton
675
+ * before the new page commits.
569
676
  */
570
677
  pendingMinMs?: number;
571
678
  /**
@@ -894,7 +1001,8 @@ export function cmsHomeRouteConfig(options: {
894
1001
  defaultDescription?: string;
895
1002
  /** Site name for OG title composition. Defaults to defaultTitle. */
896
1003
  siteName?: string;
897
- pendingComponent?: () => any;
1004
+ /** See `CmsRouteOptions.pendingComponent` — no default, deliberately. */
1005
+ pendingComponent?: (() => any) | null;
898
1006
  /** Delay (ms) before showing pending component. Default: 200. */
899
1007
  pendingMs?: number;
900
1008
  /** Minimum display time (ms) for pending component. Default: 300. */
@@ -908,6 +1016,7 @@ export function cmsHomeRouteConfig(options: {
908
1016
  defaultTitle,
909
1017
  defaultDescription,
910
1018
  siteName,
1019
+ pendingComponent,
911
1020
  pendingMs = 200,
912
1021
  pendingMinMs = 300,
913
1022
  errorComponent = CmsPageErrorFallback,
@@ -928,7 +1037,7 @@ export function cmsHomeRouteConfig(options: {
928
1037
  // See cmsRouteConfig loader for rationale.
929
1038
  return page;
930
1039
  },
931
- ...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
1040
+ ...(pendingComponent ? { pendingComponent } : {}),
932
1041
  pendingMs,
933
1042
  pendingMinMs,
934
1043
  errorComponent,
@@ -4,8 +4,8 @@ import { derivePageUrl, isClientNavigation } from "./pageUrl";
4
4
  describe("derivePageUrl (#280 — client-nav request URL)", () => {
5
5
  it("SSR real page without query: rebuilds from fullPath", () => {
6
6
  expect(
7
- derivePageUrl("/granado/produto", new URL("https://o.com/granado/produto")),
8
- ).toBe("https://o.com/granado/produto");
7
+ derivePageUrl("/acme/produto", new URL("https://o.com/acme/produto")),
8
+ ).toBe("https://o.com/acme/produto");
9
9
  });
10
10
 
11
11
  it("SSR real page preserves duplicate query params from the server URL", () => {
@@ -21,10 +21,10 @@ describe("derivePageUrl (#280 — client-nav request URL)", () => {
21
21
  it("client nav without query: rebuilds from fullPath, NOT the /_serverFn URL", () => {
22
22
  // The regression guard: getRequestUrl() is the serverFn endpoint here.
23
23
  const serverUrl = new URL(
24
- "https://o.com/_serverFn/loadCmsPage?payload=%2Fgranado%2Fproduto",
24
+ "https://o.com/_serverFn/loadCmsPage?payload=%2Facme%2Fproduto",
25
25
  );
26
- expect(derivePageUrl("/granado/produto", serverUrl)).toBe(
27
- "https://o.com/granado/produto",
26
+ expect(derivePageUrl("/acme/produto", serverUrl)).toBe(
27
+ "https://o.com/acme/produto",
28
28
  );
29
29
  });
30
30
 
@@ -50,7 +50,7 @@ describe("isClientNavigation (SPA vs SSR detection)", () => {
50
50
 
51
51
  it("SSR real page without query (path matches): false", () => {
52
52
  expect(
53
- isClientNavigation("/granado/produto", new URL("https://o.com/granado/produto")),
53
+ isClientNavigation("/acme/produto", new URL("https://o.com/acme/produto")),
54
54
  ).toBe(false);
55
55
  });
56
56
 
@@ -36,10 +36,11 @@ export function derivePageUrl(fullPath: string, serverUrl: URL): string {
36
36
  * `serverUrl.pathname` is NOT the page path. On a real document request (SSR /
37
37
  * full reload, including bots) `serverUrl.pathname === basePath`.
38
38
  *
39
- * Section deferral is a streaming-SSR optimization (shrinks the initial HTML /
40
- * TTFB). On a client navigation the server fn returns JSON in one shot — there
41
- * is no streaming benefit, so deferral only adds a round-trip and a skeleton.
42
- * Callers use this to resolve all sections eagerly on SPA navigation.
39
+ * This does NOT gate section deferral: a client nav gets the same eager/deferred
40
+ * split as SSR, because the route loader is blocking and awaiting the below-fold
41
+ * sections freezes the transition. Callers use it to rebuild the real page URL
42
+ * (`derivePageUrl`) and to tell a SPA nav apart from a genuine AJAX consumer
43
+ * (`isProgrammaticFetch`) — both send `Sec-Fetch-Dest: empty`.
43
44
  *
44
45
  * Mirrors the `serverUrl.pathname === basePath` comparison `derivePageUrl`
45
46
  * already relies on, so the two stay consistent and we avoid hardcoding the
@@ -121,6 +121,43 @@ describe("withSiteGlobals", () => {
121
121
  expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
122
122
  });
123
123
 
124
+ it("threads matcherCtx to resolvePageSections", async () => {
125
+ mockedLoadBlocks.mockReturnValue({
126
+ site: { global: [{ __resolveType: "Alert" }] },
127
+ });
128
+ mockedResolvePageSections.mockResolvedValue([{ component: "A.tsx", props: {}, key: "k0" }]);
129
+
130
+ const ctx = { path: "/farm-etc" } as any;
131
+ await resolveSiteGlobals(ctx);
132
+
133
+ expect(mockedResolvePageSections).toHaveBeenCalledWith(
134
+ [{ __resolveType: "Alert" }],
135
+ ctx,
136
+ );
137
+ });
138
+
139
+ it("caches per path — different routes don't share a matched variant", async () => {
140
+ mockedLoadBlocks.mockReturnValue({
141
+ site: { global: [{ __resolveType: "Alert" }] },
142
+ });
143
+ mockedResolvePageSections
144
+ .mockResolvedValueOnce([{ component: "EtcAlert.tsx", props: {}, key: "etc" }])
145
+ .mockResolvedValueOnce([{ component: "HomeAlert.tsx", props: {}, key: "home" }]);
146
+
147
+ const etc = await resolveSiteGlobals({ path: "/farm-etc" } as any);
148
+ const home = await resolveSiteGlobals({ path: "/" } as any);
149
+
150
+ // Distinct paths resolve independently (no cross-route leak)...
151
+ expect(etc.resolvedSections[0].key).toBe("etc");
152
+ expect(home.resolvedSections[0].key).toBe("home");
153
+ expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
154
+
155
+ // ...and the same path is still served from cache.
156
+ const etcAgain = await resolveSiteGlobals({ path: "/farm-etc" } as any);
157
+ expect(etcAgain.resolvedSections[0].key).toBe("etc");
158
+ expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
159
+ });
160
+
124
161
  it("invalidates cache when onChange fires", async () => {
125
162
  mockedLoadBlocks.mockReturnValue({
126
163
  site: { global: [{ __resolveType: "Analytics" }] },