@decocms/start 1.3.7 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "1.3.7",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -15,6 +15,7 @@ import {
15
15
  registerLayoutSections,
16
16
  } from "./sectionLoaders";
17
17
  import {
18
+ registerEagerSections,
18
19
  registerSeoSections,
19
20
  setAsyncRenderingConfig,
20
21
  getAsyncRenderingConfig,
@@ -77,6 +78,9 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
77
78
  }
78
79
 
79
80
  if (eagerSections.length > 0) {
81
+ // Permanent registry — survives subsequent setAsyncRenderingConfig() calls
82
+ registerEagerSections(eagerSections);
83
+ // Also add to alwaysEager for backward compat with code that reads the config
80
84
  const existing = getAsyncRenderingConfig() ?? {};
81
85
  setAsyncRenderingConfig({
82
86
  ...existing,
package/src/cms/index.ts CHANGED
@@ -41,13 +41,16 @@ export {
41
41
  evaluateMatcher,
42
42
  extractSeoFromProps,
43
43
  extractSeoFromSections,
44
+ cacheDeferredRawProps,
44
45
  getAsyncRenderingConfig,
46
+ getDeferredRawProps,
45
47
  isSeoSection,
46
48
  onBeforeResolve,
47
49
  registerBotPattern,
48
50
  registerCommerceLoader,
49
51
  registerCommerceLoaders,
50
52
  registerMatcher,
53
+ registerEagerSections,
51
54
  registerSeoSections,
52
55
  resolveDecoPage,
53
56
  resolvePageSections,
@@ -30,6 +30,7 @@ describe("resolveDeferredSectionFull", () => {
30
30
  component: "site/sections/ProductShelf.tsx",
31
31
  key: "site/sections/ProductShelf.tsx",
32
32
  index: 5,
33
+ propsHash: "test",
33
34
  rawProps: { title: "Best Sellers" },
34
35
  };
35
36
 
@@ -2,12 +2,14 @@ import { findPageByPath, loadBlocks } from "./loader";
2
2
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
3
3
  import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
4
4
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
5
+ import { djb2Hex } from "../sdk/djb2";
5
6
 
6
7
  // globalThis-backed: share state across Vite server function split modules
7
8
  const G = globalThis as any;
8
9
  if (!G.__deco) G.__deco = {};
9
10
  if (!G.__deco.commerceLoaders) G.__deco.commerceLoaders = {};
10
11
  if (!G.__deco.customMatchers) G.__deco.customMatchers = {};
12
+ if (!G.__deco.eagerSectionKeys) G.__deco.eagerSectionKeys = new Set<string>();
11
13
 
12
14
  // ---------------------------------------------------------------------------
13
15
  // onBeforeResolveProps helper — eagerly loads the section module if needed
@@ -69,9 +71,19 @@ export interface DeferredSection {
69
71
  key: string;
70
72
  /** Position in the original page section list. */
71
73
  index: number;
72
- /** CMS-resolved props without section-loader enrichment. */
74
+ /**
75
+ * Short hash of rawProps for client-side cache busting.
76
+ * Keeps the serialized payload small — full rawProps are resolved
77
+ * server-side from the deferred props cache or page re-resolution.
78
+ */
79
+ propsHash: string;
80
+ /**
81
+ * CMS-resolved props without section-loader enrichment.
82
+ * @deprecated Stripped before serialization to reduce HTML payload.
83
+ * Only present server-side in the rawProps cache.
84
+ */
73
85
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- rawProps: Record<string, any>;
86
+ rawProps?: Record<string, any>;
75
87
  }
76
88
 
77
89
  // ---------------------------------------------------------------------------
@@ -120,10 +132,15 @@ export function setAsyncRenderingConfig(config?: {
120
132
  alwaysEager?: string[];
121
133
  respectCmsLazy?: boolean;
122
134
  }): void {
135
+ const existing = getAsyncConfig();
136
+ const merged = new Set([
137
+ ...(existing?.alwaysEager ?? []),
138
+ ...(config?.alwaysEager ?? []),
139
+ ]);
123
140
  G.__deco.asyncConfig = {
124
- respectCmsLazy: config?.respectCmsLazy ?? true,
125
- foldThreshold: config?.foldThreshold ?? Infinity,
126
- alwaysEager: new Set(config?.alwaysEager ?? []),
141
+ respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
142
+ foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? Infinity,
143
+ alwaysEager: merged,
127
144
  };
128
145
  }
129
146
 
@@ -132,6 +149,64 @@ export function getAsyncRenderingConfig(): AsyncRenderingConfig | null {
132
149
  return getAsyncConfig();
133
150
  }
134
151
 
152
+ // ---------------------------------------------------------------------------
153
+ // Permanent eager section registry — survives setAsyncRenderingConfig() calls
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Register sections that declared `export const eager = true`.
158
+ * This is a permanent registry that cannot be overwritten by
159
+ * subsequent calls to `setAsyncRenderingConfig()`.
160
+ */
161
+ export function registerEagerSections(keys: string[]): void {
162
+ const set: Set<string> = G.__deco.eagerSectionKeys;
163
+ for (const k of keys) set.add(k);
164
+ }
165
+
166
+ function isEagerSection(key: string): boolean {
167
+ return (G.__deco.eagerSectionKeys as Set<string>).has(key);
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Deferred rawProps cache — keeps rawProps server-side to trim HTML payload
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const DEFERRED_PROPS_TTL = 120_000; // 2 minutes
175
+ const deferredRawPropsCache = new Map<string, { rawProps: Record<string, unknown>; ts: number }>();
176
+
177
+ function deferredPropsCacheKey(pagePath: string, component: string, index: number): string {
178
+ return `${pagePath}::${component}::${index}`;
179
+ }
180
+
181
+ export function cacheDeferredRawProps(
182
+ pagePath: string,
183
+ component: string,
184
+ index: number,
185
+ rawProps: Record<string, unknown>,
186
+ ): void {
187
+ const key = deferredPropsCacheKey(pagePath, component, index);
188
+ deferredRawPropsCache.set(key, { rawProps, ts: Date.now() });
189
+
190
+ // Lazy eviction: remove expired entries when cache grows
191
+ if (deferredRawPropsCache.size > 500) {
192
+ const now = Date.now();
193
+ for (const [k, v] of deferredRawPropsCache) {
194
+ if (now - v.ts > DEFERRED_PROPS_TTL) deferredRawPropsCache.delete(k);
195
+ }
196
+ }
197
+ }
198
+
199
+ export function getDeferredRawProps(
200
+ pagePath: string,
201
+ component: string,
202
+ index: number,
203
+ ): Record<string, unknown> | null {
204
+ const key = deferredPropsCacheKey(pagePath, component, index);
205
+ const entry = deferredRawPropsCache.get(key);
206
+ if (!entry || Date.now() - entry.ts > DEFERRED_PROPS_TTL) return null;
207
+ return entry.rawProps;
208
+ }
209
+
135
210
  // ---------------------------------------------------------------------------
136
211
  // Bot detection — bots always receive fully eager pages for SEO
137
212
  // ---------------------------------------------------------------------------
@@ -932,6 +1007,8 @@ function shouldDeferSection(
932
1007
  const finalKey = resolveFinalSectionKey(section, matcherCtx);
933
1008
  if (!finalKey) return false;
934
1009
 
1010
+ // Permanent registry — `export const eager = true` cannot be clobbered
1011
+ if (isEagerSection(finalKey)) return false;
935
1012
  if (cfg.alwaysEager.has(finalKey)) return false;
936
1013
  if (isLayoutSection(finalKey)) return false;
937
1014
 
@@ -1024,6 +1101,7 @@ function resolveSectionShallow(
1024
1101
  component: rt,
1025
1102
  key: rt,
1026
1103
  index: -1,
1104
+ propsHash: djb2Hex(JSON.stringify(rawProps)),
1027
1105
  rawProps: rawProps as Record<string, unknown>,
1028
1106
  };
1029
1107
  }
@@ -1241,6 +1319,14 @@ export async function resolveDecoPage(
1241
1319
  const deferred = resolveSectionShallow(section, ctx);
1242
1320
  if (deferred) {
1243
1321
  deferred.index = currentFlatIndex;
1322
+
1323
+ // Cache rawProps server-side and strip from the deferred object
1324
+ // so they are NOT serialized into the HTML payload.
1325
+ if (deferred.rawProps) {
1326
+ cacheDeferredRawProps(targetPath, deferred.component, currentFlatIndex, deferred.rawProps);
1327
+ delete deferred.rawProps;
1328
+ }
1329
+
1244
1330
  deferredSections.push(deferred);
1245
1331
  deferredOk = true;
1246
1332
  }
@@ -1441,9 +1527,16 @@ export async function resolveDeferredSectionFull(
1441
1527
  request: Request,
1442
1528
  matcherCtx?: MatcherContext,
1443
1529
  ): Promise<ResolvedSection | null> {
1530
+ // rawProps may be stripped from the client payload — resolve from cache or page
1531
+ const rawProps = ds.rawProps
1532
+ ?? getDeferredRawProps(pagePath, ds.component, ds.index)
1533
+ ?? await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx);
1534
+
1535
+ if (!rawProps) return null;
1536
+
1444
1537
  const section = await resolveDeferredSection(
1445
1538
  ds.component,
1446
- ds.rawProps,
1539
+ rawProps,
1447
1540
  pagePath,
1448
1541
  matcherCtx,
1449
1542
  );
@@ -1452,3 +1545,44 @@ export async function resolveDeferredSectionFull(
1452
1545
  const enriched = await runSingleSectionLoader(section, request);
1453
1546
  return normalizeUrlsInObject(enriched);
1454
1547
  }
1548
+
1549
+ /**
1550
+ * Fallback for deferred rawProps cache miss: re-resolve the page and extract
1551
+ * rawProps for the section at the given index. Expensive but ensures correctness
1552
+ * when the in-memory cache has been evicted (different isolate, TTL expired).
1553
+ */
1554
+ export async function reExtractRawProps(
1555
+ pagePath: string,
1556
+ component: string,
1557
+ sectionIndex: number,
1558
+ matcherCtx?: MatcherContext,
1559
+ ): Promise<Record<string, unknown> | null> {
1560
+ ensureInitialized();
1561
+
1562
+ const match = findPageByPath(pagePath);
1563
+ if (!match) return null;
1564
+
1565
+ const { page } = match;
1566
+ const ctx: MatcherContext = { ...matcherCtx, path: pagePath };
1567
+
1568
+ let rawSections: unknown[];
1569
+ if (Array.isArray(page.sections)) {
1570
+ rawSections = page.sections;
1571
+ } else {
1572
+ const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
1573
+ rawSections = await resolveSectionsList(page.sections, rctx);
1574
+ }
1575
+
1576
+ if (sectionIndex < 0 || sectionIndex >= rawSections.length) return null;
1577
+
1578
+ const section = rawSections[sectionIndex];
1579
+ const shallow = resolveSectionShallow(section, ctx);
1580
+ if (!shallow || shallow.component !== component) return null;
1581
+
1582
+ // Cache for subsequent requests
1583
+ if (shallow.rawProps) {
1584
+ cacheDeferredRawProps(pagePath, component, sectionIndex, shallow.rawProps);
1585
+ }
1586
+
1587
+ return shallow.rawProps ?? null;
1588
+ }
@@ -19,7 +19,7 @@ import {
19
19
  setResolvedComponent,
20
20
  } from "../cms/registry";
21
21
  import type { DeferredSection, ResolvedSection } from "../cms/resolve";
22
- import { djb2Hex } from "../sdk/djb2";
22
+
23
23
  import { SectionErrorBoundary } from "./SectionErrorFallback";
24
24
 
25
25
  type LazyComponent = ReturnType<typeof lazy>;
@@ -235,9 +235,10 @@ interface DeferredSectionWrapperProps {
235
235
  errorFallback?: ReactNode;
236
236
  loadFn: (data: {
237
237
  component: string;
238
- rawProps: Record<string, unknown>;
238
+ rawProps?: Record<string, unknown>;
239
239
  pagePath: string;
240
240
  pageUrl?: string;
241
+ index?: number;
241
242
  }) => Promise<ResolvedSection | null>;
242
243
  }
243
244
 
@@ -249,8 +250,7 @@ function DeferredSectionWrapper({
249
250
  errorFallback,
250
251
  loadFn,
251
252
  }: DeferredSectionWrapperProps) {
252
- const propsHash = djb2Hex(JSON.stringify(deferred.rawProps));
253
- const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${propsHash}`;
253
+ const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${deferred.propsHash ?? ""}`;
254
254
  const [section, setSection] = useState<ResolvedSection | null>(() =>
255
255
  typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
256
256
  );
@@ -308,9 +308,9 @@ function DeferredSectionWrapper({
308
308
  const key0 = stableKey;
309
309
  loadFn({
310
310
  component: deferred.component,
311
- rawProps: deferred.rawProps,
312
311
  pagePath,
313
312
  pageUrl,
313
+ index: deferred.index,
314
314
  })
315
315
  .then((result) => {
316
316
  if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
@@ -328,9 +328,9 @@ function DeferredSectionWrapper({
328
328
  const key1 = stableKey;
329
329
  loadFn({
330
330
  component: deferred.component,
331
- rawProps: deferred.rawProps,
332
331
  pagePath,
333
332
  pageUrl,
333
+ index: deferred.index,
334
334
  })
335
335
  .then((result) => {
336
336
  if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
@@ -344,7 +344,7 @@ function DeferredSectionWrapper({
344
344
 
345
345
  observer.observe(el);
346
346
  return () => observer.disconnect();
347
- }, [deferred.component, deferred.rawProps, pagePath, pageUrl, section, loadFn]);
347
+ }, [deferred.component, deferred.index, deferred.propsHash, pagePath, pageUrl, section, loadFn]);
348
348
 
349
349
  if (error) {
350
350
  const errFallback = loadedOptions?.errorFallback
@@ -402,7 +402,9 @@ function DeferredSectionSkeleton({
402
402
  }) {
403
403
  const options = getSectionOptions(deferred.component);
404
404
  if (options?.loadingFallback) {
405
- return createElement(options.loadingFallback, deferred.rawProps);
405
+ // rawProps are no longer serialized to the client — pass empty object.
406
+ // LoadingFallback components should be pure layout skeletons.
407
+ return createElement(options.loadingFallback, deferred.rawProps ?? {});
406
408
  }
407
409
  if (fallback) return <>{fallback}</>;
408
410
  if (isDev) return <DevMissingFallbackWarning component={deferred.component} />;
@@ -466,9 +468,10 @@ interface Props {
466
468
  /** @deprecated Use deferredPromises instead (TanStack native streaming). */
467
469
  loadDeferredSectionFn?: (data: {
468
470
  component: string;
469
- rawProps: Record<string, unknown>;
471
+ rawProps?: Record<string, unknown>;
470
472
  pagePath: string;
471
473
  pageUrl?: string;
474
+ index?: number;
472
475
  }) => Promise<ResolvedSection | null>;
473
476
  }
474
477
 
@@ -35,6 +35,8 @@ import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "
35
35
  import {
36
36
  extractSeoFromProps,
37
37
  extractSeoFromSections,
38
+ getDeferredRawProps,
39
+ reExtractRawProps,
38
40
  resolveDecoPage,
39
41
  resolveDeferredSection,
40
42
  resolveDeferredSectionFull,
@@ -198,7 +200,8 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
198
200
  (data: unknown) =>
199
201
  data as {
200
202
  component: string;
201
- rawProps: Record<string, any>;
203
+ /** @deprecated rawProps are now resolved server-side from the deferred props cache. */
204
+ rawProps?: Record<string, any>;
202
205
  pagePath: string;
203
206
  pageUrl?: string;
204
207
  /** Original position in the page section list — preserved for correct SPA ordering. */
@@ -206,7 +209,7 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
206
209
  },
207
210
  )
208
211
  .handler(async (ctx) => {
209
- const { component, rawProps, pagePath, pageUrl, index } = ctx.data;
212
+ const { component, rawProps: clientRawProps, pagePath, pageUrl, index } = ctx.data;
210
213
 
211
214
  const originRequest = getRequest();
212
215
  const serverUrl = getRequestUrl().toString();
@@ -218,6 +221,20 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
218
221
  request: originRequest,
219
222
  };
220
223
 
224
+ // Resolve rawProps: prefer client-provided (backward compat), then server cache,
225
+ // then re-extract from the page as a last resort (handles cross-isolate cache miss
226
+ // on Cloudflare Workers and TTL expiry for slow-scrolling users).
227
+ const rawProps = clientRawProps
228
+ ?? (index !== undefined ? getDeferredRawProps(pagePath, component, index) : null)
229
+ ?? (index !== undefined
230
+ ? await reExtractRawProps(pagePath, component, index, matcherCtx)
231
+ : null);
232
+
233
+ if (!rawProps) {
234
+ console.warn(`[CMS] Deferred section cache miss: ${component} at index ${index} on ${pagePath}`);
235
+ return null;
236
+ }
237
+
221
238
  const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
222
239
  if (!section) return null;
223
240
 
@@ -253,14 +270,16 @@ export const deferredSectionLoader = async ({
253
270
  rawProps,
254
271
  pagePath,
255
272
  pageUrl,
273
+ index,
256
274
  }: {
257
275
  component: string;
258
- rawProps: Record<string, unknown>;
276
+ rawProps?: Record<string, unknown>;
259
277
  pagePath: string;
260
278
  pageUrl?: string;
279
+ index?: number;
261
280
  }): Promise<ResolvedSection | null> => {
262
281
  return loadDeferredSection({
263
- data: { component, rawProps, pagePath, pageUrl },
282
+ data: { component, rawProps, pagePath, pageUrl, index },
264
283
  });
265
284
  };
266
285
 
@@ -131,12 +131,40 @@ export function decoVitePlugin() {
131
131
  ) {
132
132
  return "vendor-react";
133
133
  }
134
+
135
+ // TanStack Router — client-side router (always needed)
134
136
  if (
135
137
  id.includes("@tanstack/react-router") ||
136
- id.includes("@tanstack/start")
138
+ id.includes("@tanstack/router-core")
139
+ ) {
140
+ return "vendor-router";
141
+ }
142
+
143
+ // TanStack Start — specific checks before broad catch-all
144
+ // (react-start-client includes "react-start" so must come first)
145
+ if (
146
+ id.includes("@tanstack/react-start-client") ||
147
+ id.includes("@tanstack/start-client-core")
148
+ ) {
149
+ return "vendor-router";
150
+ }
151
+ // Server-only TanStack packages — let Rollup tree-shake
152
+ if (
153
+ id.includes("@tanstack/react-start-server") ||
154
+ id.includes("@tanstack/start-server-core")
137
155
  ) {
156
+ return undefined;
157
+ }
158
+ // Remaining @tanstack/start (storage-context, plugin-core, etc.)
159
+ if (id.includes("@tanstack/start")) {
138
160
  return "vendor-router";
139
161
  }
162
+
163
+ // isbot — server-only (bot detection in resolve.ts)
164
+ if (id.includes("node_modules/isbot")) {
165
+ return undefined;
166
+ }
167
+
140
168
  if (id.includes("@tanstack/react-query")) {
141
169
  return "vendor-query";
142
170
  }