@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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>;
|