@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/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 {
|
|
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 {
|
|
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
|
+
});
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -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] =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
282
|
-
// for the
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
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
|
-
/**
|
|
551
|
-
|
|
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
|
|
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
|
-
...(
|
|
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("/
|
|
8
|
-
).toBe("https://o.com/
|
|
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=%
|
|
24
|
+
"https://o.com/_serverFn/loadCmsPage?payload=%2Facme%2Fproduto",
|
|
25
25
|
);
|
|
26
|
-
expect(derivePageUrl("/
|
|
27
|
-
"https://o.com/
|
|
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("/
|
|
53
|
+
isClientNavigation("/acme/produto", new URL("https://o.com/acme/produto")),
|
|
54
54
|
).toBe(false);
|
|
55
55
|
});
|
|
56
56
|
|
package/src/routes/pageUrl.ts
CHANGED
|
@@ -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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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" }] },
|