@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -9,13 +9,20 @@
9
9
  "directory": "packages/tanstack"
10
10
  },
11
11
  "main": "./src/index.ts",
12
+ "sideEffects": [
13
+ "./src/vite/**",
14
+ "./src/daemon/**"
15
+ ],
12
16
  "exports": {
13
17
  ".": "./src/index.ts",
14
18
  "./vite": "./src/vite/plugin.js",
15
19
  "./daemon": "./src/daemon/index.ts",
16
20
  "./sdk/createInvoke": "./src/sdk/createInvoke.ts",
17
21
  "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts",
18
- "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts"
22
+ "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts",
23
+ "./sdk/serverFnFetch": "./src/sdk/serverFnFetch.ts",
24
+ "./sdk/startEntry": "./src/sdk/startEntry.ts",
25
+ "./sdk/cdnSegment": "./src/sdk/cdnSegment.ts"
19
26
  },
20
27
  "scripts": {
21
28
  "build": "tsc",
@@ -24,9 +31,9 @@
24
31
  "lint:unused": "knip"
25
32
  },
26
33
  "dependencies": {
27
- "@decocms/blocks": "8.0.0",
28
- "@decocms/blocks-admin": "8.0.0",
29
- "@decocms/blocks-cli": "8.0.0",
34
+ "@decocms/blocks": "8.1.0-next.0",
35
+ "@decocms/blocks-admin": "8.1.0-next.0",
36
+ "@decocms/blocks-cli": "8.1.0-next.0",
30
37
  "@deco-cx/warp-node": "^0.3.16",
31
38
  "fast-json-patch": "^3.1.0",
32
39
  "ws": "^8.18.0"
@@ -0,0 +1,221 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Conformance: the framework-binding half of telemetry.mdx ("What's sent"),
4
+ * telemetry-internals.mdx ("Sending in the background") and caching.mdx
5
+ * ("Upstream data"), for `@decocms/tanstack` on Cloudflare Workers.
6
+ *
7
+ * A site on Workers creates its CMS with `createCMS({ telemetry })` from
8
+ * `@decocms/blocks` and serves through the binding's worker entry. The docs
9
+ * say the binding then adds inbound request and edge-cache metrics to that
10
+ * CMS's telemetry, sends batches inside `ctx.waitUntil`, and caches upstream
11
+ * responses (made through `createInstrumentedFetch`) with the Cache API,
12
+ * except while a draft is being rendered.
13
+ */
14
+ import { gunzipSync } from "node:zlib";
15
+ import { createCMS, DRAFT_COOKIE, resetForTests } from "@decocms/blocks";
16
+ import { setBlocks } from "@decocms/blocks/cms";
17
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+ import { createDecoWorkerEntry } from "../sdk/workerEntry";
20
+
21
+ const ENDPOINT = "https://otel.example.com";
22
+
23
+ let clock = 0;
24
+ let waits: Promise<unknown>[] = [];
25
+ const ctx = {
26
+ waitUntil: (promise: Promise<unknown>) => void waits.push(promise),
27
+ passThroughOnException: () => {},
28
+ };
29
+
30
+ interface Sent {
31
+ url: string;
32
+ text: string;
33
+ body: any;
34
+ }
35
+
36
+ let sent: Sent[] = [];
37
+ let upstreamCalls: string[] = [];
38
+
39
+ beforeEach(() => {
40
+ resetForTests();
41
+ // The only worker entry @decocms/tanstack has is v7's, which needs v7's block
42
+ // registry loaded even for an app that only uses createCMS.
43
+ setBlocks({});
44
+ clock = 0;
45
+ waits = [];
46
+ sent = [];
47
+ upstreamCalls = [];
48
+ const now = Date.now.bind(Date);
49
+ vi.spyOn(Date, "now").mockImplementation(() => now() + clock);
50
+ vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "");
51
+ vi.stubGlobal(
52
+ "fetch",
53
+ vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
54
+ const url = String(input instanceof Request ? input.url : input);
55
+ if (url.startsWith(ENDPOINT)) {
56
+ const raw = Buffer.from(init?.body as Uint8Array);
57
+ const gz = new Headers(init?.headers).get("content-encoding") === "gzip";
58
+ const text = gz ? gunzipSync(raw).toString() : String(raw);
59
+ sent.push({ url, text, body: JSON.parse(text) });
60
+ return new Response(null, { status: 200 });
61
+ }
62
+ upstreamCalls.push(url);
63
+ return new Response(JSON.stringify({ hits: [] }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json", "cache-control": "max-age=60" },
66
+ });
67
+ }),
68
+ );
69
+ });
70
+
71
+ afterEach(() => {
72
+ delete (globalThis as Record<symbol, unknown>)[Symbol.for("decocms.blocks.background")];
73
+ vi.unstubAllGlobals();
74
+ vi.unstubAllEnvs();
75
+ vi.restoreAllMocks();
76
+ resetForTests();
77
+ });
78
+
79
+ /** A Cache API stand-in (`caches.default`) that remembers what was put. */
80
+ function fakeCaches() {
81
+ const store = new Map<string, Response>();
82
+ const puts: string[] = [];
83
+ const cache = {
84
+ match: vi.fn(async (request: Request | string) => {
85
+ const key = typeof request === "string" ? request : request.url;
86
+ return store.get(key)?.clone();
87
+ }),
88
+ put: vi.fn(async (request: Request | string, response: Response) => {
89
+ const key = typeof request === "string" ? request : request.url;
90
+ puts.push(key);
91
+ store.set(key, response.clone());
92
+ }),
93
+ delete: vi.fn(async () => true),
94
+ };
95
+ vi.stubGlobal("caches", { default: cache, open: async () => cache });
96
+ return { cache, puts };
97
+ }
98
+
99
+ /** Lets every queued background task run, one batch window (10 s) after the last. */
100
+ async function drain(): Promise<void> {
101
+ for (let i = 0; i < 5; i++) {
102
+ clock += 10_000;
103
+ const pending = waits.splice(0);
104
+ if (pending.length === 0) break;
105
+ await Promise.all(pending);
106
+ }
107
+ }
108
+
109
+ function metrics() {
110
+ return sent
111
+ .filter((s) => s.url.endsWith("/v1/metrics"))
112
+ .flatMap((s) => s.body.resourceMetrics[0].scopeMetrics[0].metrics);
113
+ }
114
+
115
+ function attrs(list: { key: string; value: Record<string, unknown> }[] = []) {
116
+ return Object.fromEntries(list.map(({ key, value }) => [key, Object.values(value)[0]]));
117
+ }
118
+
119
+ const search = createInstrumentedFetch({ provider: "acme-search" });
120
+
121
+ /** The app's server entry: a product page that calls an upstream API through the instrumented fetch. */
122
+ const serverEntry = {
123
+ fetch: async (request: Request) => {
124
+ const url = new URL(request.url);
125
+ if (url.pathname.startsWith("/products/")) {
126
+ await search("https://search.example/v1/search?q=shirt", { operation: "search" });
127
+ return new Response("<html>product</html>", {
128
+ status: 200,
129
+ headers: { "content-type": "text/html" },
130
+ });
131
+ }
132
+ return new Response("not found", { status: 404 });
133
+ },
134
+ };
135
+
136
+ function cms() {
137
+ return createCMS({
138
+ blocks: {},
139
+ content: { revision: "rev-1", root: "tanstack-conformance/.deco", blocks: {} },
140
+ telemetry: { endpoint: ENDPOINT },
141
+ });
142
+ }
143
+
144
+ async function serve(worker: ReturnType<typeof createDecoWorkerEntry>, path: string, headers = {}) {
145
+ const response = await worker.fetch(
146
+ new Request(`https://shop.example${path}`, { headers }),
147
+ {},
148
+ ctx,
149
+ );
150
+ await response.text();
151
+ return response;
152
+ }
153
+
154
+ describe("sending in the background (telemetry-internals.mdx)", () => {
155
+ it("tin-06: on Workers, batches go out after the response, inside ctx.waitUntil", async () => {
156
+ cms();
157
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
158
+ await serve(worker, "/products/abc-123");
159
+ expect(sent).toEqual([]); // nothing went out in front of the response
160
+ clock += 10_000;
161
+ await serve(worker, "/products/abc-123");
162
+ expect(waits.length).toBeGreaterThan(0);
163
+ await drain();
164
+ expect(metrics().map((m) => m.name)).toContain("http.client.request.duration");
165
+ });
166
+ });
167
+
168
+ describe("what the binding adds (telemetry.mdx › What's sent)", () => {
169
+ it("tel-09: the binding adds no measurements of its own (inbound requests and the page cache are the framework's)", async () => {
170
+ cms();
171
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
172
+ await serve(worker, "/products/abc-123?x=1");
173
+ clock += 10_000;
174
+ await serve(worker, "/products/abc-123?x=1");
175
+ await drain();
176
+ const names = new Set(metrics().map((m) => m.name));
177
+ expect(names).toContain("http.client.request.duration");
178
+ expect(names).not.toContain("http.server.request.duration");
179
+ expect(names).not.toContain("deco.cache.requests");
180
+ expect([...names].every((name) => name === "http.client.request.duration")).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe("upstream data (caching.mdx)", () => {
185
+ it("cache-03/tel-15/cache-05: upstream GETs are cached with the Cache API; a hit is labeled cached=true", async () => {
186
+ const { cache } = fakeCaches();
187
+ cms();
188
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
189
+ // Two different pages (so the page cache can't answer) calling the same upstream URL.
190
+ await serve(worker, "/products/a");
191
+ await serve(worker, "/products/b");
192
+ expect(upstreamCalls).toHaveLength(1);
193
+ expect(cache.put).toHaveBeenCalled();
194
+ clock += 10_000;
195
+ await serve(worker, "/products/other");
196
+ await drain();
197
+ const client = metrics().find((m) => m.name === "http.client.request.duration");
198
+ const labels = client.histogram.dataPoints.map((p: any) => attrs(p.attributes).cached);
199
+ expect(labels).toContain(true);
200
+ expect(labels).toContain(false);
201
+ });
202
+
203
+ it("cache-06: while a draft is rendered, upstream calls bypass the cache", async () => {
204
+ const { cache, puts } = fakeCaches();
205
+ cms();
206
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
207
+ // Without a draft, the second call is a cache hit (the precondition).
208
+ await serve(worker, "/products/a");
209
+ await serve(worker, "/products/b");
210
+ expect(upstreamCalls).toHaveLength(1);
211
+ const putsBefore = puts.length;
212
+ cache.match.mockClear();
213
+ const draft = {
214
+ cookie: `${DRAFT_COOKIE}=${encodeURIComponent("content.example/drafts/1@v1")}`,
215
+ };
216
+ await serve(worker, "/products/c", draft);
217
+ await serve(worker, "/products/d", draft);
218
+ expect(upstreamCalls).toHaveLength(3);
219
+ expect(puts.length).toBe(putsBefore);
220
+ });
221
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Publishes the CDN segment token this server computed, so `decoServerFnFetch`
3
+ * can echo it back on `/_serverFn` URLs.
4
+ *
5
+ * Only the server can produce this value: the segment includes dimensions the
6
+ * browser cannot observe — region is resolved from `request.cf.regionCode`.
7
+ * A client that derives its own token therefore never matches on a
8
+ * regionalized store, which is exactly why this component exists.
9
+ *
10
+ * - **Server:** read the token from the RequestContext bag (filled by
11
+ * `createDecoWorkerEntry`) and emit a tiny inline `<script>`.
12
+ * - **Client:** the bag is a no-op stub, so the read yields undefined and this
13
+ * renders nothing — the global set during SSR is already on `window`.
14
+ *
15
+ * Safe in a cached response: the Worker's edge cache keys on the segment, so a
16
+ * stored entry carries the token of its own segment. And a stale token is
17
+ * harmless by construction — the Worker verifies by recomputing and falls back
18
+ * to `no-store` when it doesn't match.
19
+ */
20
+
21
+ import { getRequestNonce } from "@decocms/blocks/sdk/nonce";
22
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
23
+ import { CSEG_BAG_KEY, CSEG_GLOBAL } from "../sdk/cdnSegment";
24
+
25
+ export function CdnSegmentMarker() {
26
+ const token = RequestContext.getBag<string>(CSEG_BAG_KEY);
27
+ if (!token) return null;
28
+ return (
29
+ <script
30
+ nonce={getRequestNonce()}
31
+ // A hash this server computed, not user input. JSON.stringify keeps it
32
+ // inert regardless.
33
+ dangerouslySetInnerHTML={{
34
+ __html: `window.${CSEG_GLOBAL}=${JSON.stringify(token)}`,
35
+ }}
36
+ />
37
+ );
38
+ }
@@ -11,6 +11,7 @@ import {
11
11
  import { Await, ClientOnly } from "@tanstack/react-router";
12
12
  import type { SectionOptions } from "@decocms/blocks/cms/client";
13
13
  import {
14
+ getDeferredTrigger,
14
15
  getResolvedComponent,
15
16
  getSectionOptions,
16
17
  getSectionRegistry,
@@ -307,9 +308,9 @@ function DeferredSectionWrapper({
307
308
  const el = ref.current;
308
309
  if (!el) return;
309
310
 
310
- if (typeof IntersectionObserver === "undefined") {
311
+ const key = stableKey;
312
+ const load = () => {
311
313
  triggered.current = true;
312
- const key0 = stableKey;
313
314
  loadFn({
314
315
  component: deferred.component,
315
316
  pagePath,
@@ -317,37 +318,72 @@ function DeferredSectionWrapper({
317
318
  index: deferred.index,
318
319
  })
319
320
  .then((result) => {
320
- if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
321
+ if (result) deferredSectionCache.set(key, { section: result, ts: Date.now() });
321
322
  setSection(result);
322
323
  })
323
324
  .catch((e) => setError(e));
325
+ };
326
+
327
+ // `"load"` — dispatch on mount, no scroll needed.
328
+ //
329
+ // This is what Deco on Fresh does: `DispatchAsyncRender` with
330
+ // `partialTriggerMode: "load"` clicks the hidden partial trigger as soon as
331
+ // the island hydrates, so a page ships a light skeleton HTML and then fills
332
+ // in every deferred section about half a second later. Sites migrating from
333
+ // Fresh need it for parity — under `"intersection"` alone, everything below
334
+ // the fold does not exist until the user scrolls: the document stays short
335
+ // and below-the-fold analytics impressions never fire.
336
+ //
337
+ // Opt-in per site via `setAsyncRenderingConfig({ deferredTrigger: "load" })`;
338
+ // the default stays `"intersection"`, so a framework bump changes nothing.
339
+ if (getDeferredTrigger() === "load" || typeof IntersectionObserver === "undefined") {
340
+ load();
324
341
  return;
325
342
  }
326
343
 
327
- const observer = new IntersectionObserver(
328
- ([entry]) => {
329
- if (entry?.isIntersecting && !triggered.current) {
330
- triggered.current = true;
331
- observer.disconnect();
332
- const key1 = stableKey;
333
- loadFn({
334
- component: deferred.component,
335
- pagePath,
336
- pageUrl,
337
- index: deferred.index,
338
- })
339
- .then((result) => {
340
- if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
341
- setSection(result);
342
- })
343
- .catch((e) => setError(e));
344
- }
345
- },
346
- { rootMargin: "300px" },
347
- );
348
-
349
- observer.observe(el);
350
- return () => observer.disconnect();
344
+ let observer: IntersectionObserver | undefined;
345
+ const startObserving = () => {
346
+ if (triggered.current) return;
347
+ observer = new IntersectionObserver(
348
+ ([entry]) => {
349
+ if (entry?.isIntersecting && !triggered.current) {
350
+ observer?.disconnect();
351
+ load();
352
+ }
353
+ },
354
+ { rootMargin: "300px" },
355
+ );
356
+ observer.observe(el);
357
+ };
358
+
359
+ // Wait one frame before observing, so the router's scroll reset lands first.
360
+ //
361
+ // On a client (SPA) navigation the scroll position is still wherever the
362
+ // user left the PREVIOUS page when this effect runs. TanStack resets it from
363
+ // the `onRendered` event, which is emitted in a `useLayoutEffect`
364
+ // (react-router's `OnRendered`) that depends on the `resolvedLocation` store
365
+ // — and that store is written from ANOTHER `useLayoutEffect` (Transitioner).
366
+ // So the reset necessarily lands one commit after the one that mounts these
367
+ // skeletons, and React flushes this commit's passive effects before starting
368
+ // that follow-up render. Verified ordering: observe → scroll reset.
369
+ //
370
+ // Observing synchronously therefore evaluates intersection against the stale
371
+ // scroll offset: a user navigating from the bottom of a long page has every
372
+ // skeleton "in view" at once, so every deferred section fires its serverFn
373
+ // POST simultaneously on commit — the exact thundering herd deferral exists
374
+ // to avoid. One frame is enough to let the reset apply.
375
+ //
376
+ // `triggered` is re-checked inside, so a section that resolved from cache in
377
+ // the meantime never starts an observer.
378
+ if (typeof requestAnimationFrame === "undefined") {
379
+ startObserving();
380
+ return () => observer?.disconnect();
381
+ }
382
+ const raf = requestAnimationFrame(startObserving);
383
+ return () => {
384
+ cancelAnimationFrame(raf);
385
+ observer?.disconnect();
386
+ };
351
387
  }, [deferred.component, deferred.index, deferred.propsHash, pagePath, pageUrl, section, loadFn]);
352
388
 
353
389
  if (error) {
@@ -406,9 +442,22 @@ function DeferredSectionWrapper({
406
442
  .replace(/\.tsx$/, "")
407
443
  .replace(/^site-sections-/, "");
408
444
 
445
+ // Wrap in the same SectionErrorBoundary the resolved branches use above.
446
+ // Without this, the child at this position goes straight from `skeleton`
447
+ // to `<SectionErrorBoundary>...</SectionErrorBoundary>` once `section` is
448
+ // set — a type change React can't reconcile, forcing a full unmount of
449
+ // the skeleton subtree and mount of the resolved subtree even when the
450
+ // section's own LoadingFallback renders the real component with reduced
451
+ // props (a documented convention — see Footer.tsx/ETCImageContent.tsx in
452
+ // a production storefront migration's T64). That unmount/remount is what
453
+ // produces a hard rect-collapse-to-{0,0,0,0} layout-shift entry instead
454
+ // of an ordinary prop-driven resize. Keeping the wrapper identical across
455
+ // both branches lets React diff by type at the child position instead.
409
456
  return (
410
457
  <section ref={ref} id={sectionId} data-manifest-key={deferred.key} data-deferred="true">
411
- {skeleton}
458
+ <SectionErrorBoundary sectionKey={deferred.key} fallback={errorFallback}>
459
+ {skeleton}
460
+ </SectionErrorBoundary>
412
461
  </section>
413
462
  );
414
463
  }
@@ -482,6 +531,11 @@ interface Props {
482
531
  * Unawaited promises for deferred sections, keyed by `d_<index>`.
483
532
  * Created by the route loader for TanStack native SSR streaming.
484
533
  * When provided, takes precedence over `loadDeferredSectionFn`.
534
+ *
535
+ * SSR-ONLY: promises cannot cross the server-fn JSON boundary, so a client
536
+ * (SPA) navigation never receives them. Any site that defers sections needs
537
+ * `loadDeferredSectionFn` wired regardless — it is the only path that works
538
+ * for both SSR and client nav.
485
539
  */
486
540
  deferredPromises?: Record<string, Promise<ResolvedSection | null>>;
487
541
  pagePath?: string;
@@ -496,7 +550,15 @@ interface Props {
496
550
  device?: Device;
497
551
  loadingFallback?: ReactNode;
498
552
  errorFallback?: ReactNode;
499
- /** @deprecated Use deferredPromises instead (TanStack native streaming). */
553
+ /**
554
+ * IntersectionObserver-driven loader for deferred sections — wire this to
555
+ * `deferredSectionLoader` from `@decocms/tanstack`.
556
+ *
557
+ * NOT deprecated: `deferredPromises` only covers SSR (promises can't be
558
+ * serialized through a server fn), so this is the only deferred-resolution
559
+ * path available on a client (SPA) navigation. Without it, deferred sections
560
+ * on a SPA transition render a skeleton that never resolves.
561
+ */
500
562
  loadDeferredSectionFn?: (data: {
501
563
  component: string;
502
564
  rawProps?: Record<string, unknown>;