@decocms/start 0.26.0 → 0.26.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": "0.26.0",
3
+ "version": "0.26.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",
@@ -1,20 +1,60 @@
1
1
  import { createElement } from "react";
2
2
  import { loadBlocks, withBlocksOverride } from "../cms/loader";
3
3
  import { getSection } from "../cms/registry";
4
- import { resolveValue, WELL_KNOWN_TYPES } from "../cms/resolve";
4
+ import {
5
+ type MatcherContext,
6
+ type ResolvedSection,
7
+ resolvePageSections,
8
+ resolveValue,
9
+ WELL_KNOWN_TYPES,
10
+ } from "../cms/resolve";
11
+ import { runSingleSectionLoader } from "../cms/sectionLoaders";
5
12
  import { buildHtmlShell } from "../sdk/htmlShell";
6
13
  import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
7
14
  import { getPreviewWrapper } from "./setup";
8
15
 
9
16
  export { setRenderShell, setPreviewWrapper } from "./setup";
10
17
 
18
+ // Cache the dynamic import — avoids re-importing per section render
19
+ let _renderToString: ((element: any) => string) | null = null;
20
+ async function getRenderToString() {
21
+ if (!_renderToString) {
22
+ const mod = await import("react-dom/server");
23
+ _renderToString = mod.renderToString;
24
+ }
25
+ return _renderToString;
26
+ }
27
+
11
28
  function wrapInHtmlShell(sectionHtml: string): string {
12
29
  return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
13
30
  }
14
31
 
15
32
  /**
16
- * Render a single resolved section object to an HTML string.
17
- * Returns empty string for unknown or SEO-only sections.
33
+ * Render a single ResolvedSection to an HTML string.
34
+ * Uses the pre-cached renderToString and the preview wrapper.
35
+ */
36
+ async function renderResolvedSection(section: ResolvedSection): Promise<string> {
37
+ const sectionLoader = getSection(section.component);
38
+ if (!sectionLoader) {
39
+ return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${section.component}</div>`;
40
+ }
41
+
42
+ try {
43
+ const renderToString = await getRenderToString();
44
+ const mod = await sectionLoader();
45
+ const element = createElement(mod.default, section.props);
46
+ const Wrapper = getPreviewWrapper();
47
+ const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
48
+ return renderToString(wrapped);
49
+ } catch (error) {
50
+ return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${section.component}: ${(error as Error).message}</div>`;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Render a single raw section object (with __resolveType) to HTML.
56
+ * Kept for the single-section preview path where we don't go through
57
+ * resolvePageSections.
18
58
  */
19
59
  async function renderOneSection(section: Record<string, unknown>): Promise<string> {
20
60
  const resolveType = section.__resolveType as string | undefined;
@@ -27,7 +67,7 @@ async function renderOneSection(section: Record<string, unknown>): Promise<strin
27
67
 
28
68
  try {
29
69
  const { __resolveType: _, ...sectionProps } = section;
30
- const { renderToString } = await import("react-dom/server");
70
+ const renderToString = await getRenderToString();
31
71
  const mod = await sectionLoader();
32
72
  const element = createElement(mod.default, sectionProps);
33
73
  const Wrapper = getPreviewWrapper();
@@ -38,6 +78,37 @@ async function renderOneSection(section: Record<string, unknown>): Promise<strin
38
78
  }
39
79
  }
40
80
 
81
+ /**
82
+ * Build a MatcherContext from the preview request.
83
+ * Enables matchers (device, date, cookie, etc.) to evaluate correctly
84
+ * during preview resolution.
85
+ */
86
+ function buildPreviewMatcherCtx(request: Request): MatcherContext {
87
+ const url = new URL(request.url);
88
+ const deviceHint = url.searchParams.get("deviceHint");
89
+ const path = url.searchParams.get("path") || "/";
90
+
91
+ let userAgent = request.headers.get("user-agent") ?? "";
92
+ if (deviceHint === "mobile" && !/mobile/i.test(userAgent)) {
93
+ userAgent += " Mobile";
94
+ }
95
+
96
+ const cookieHeader = request.headers.get("cookie") ?? "";
97
+ const cookies: Record<string, string> = {};
98
+ for (const part of cookieHeader.split(";")) {
99
+ const [k, ...v] = part.trim().split("=");
100
+ if (k) cookies[k.trim()] = v.join("=").trim();
101
+ }
102
+
103
+ return {
104
+ userAgent,
105
+ url: url.toString(),
106
+ path,
107
+ cookies,
108
+ request,
109
+ };
110
+ }
111
+
41
112
  /**
42
113
  * Handles /live/previews/* -- renders sections to HTML for the admin preview.
43
114
  *
@@ -121,28 +192,30 @@ export async function handleRender(request: Request): Promise<Response> {
121
192
  }
122
193
  }
123
194
 
124
- // Page compositor: resolve + render all child sections
195
+ // Page compositor: resolve + render all child sections in parallel
125
196
  if (component === WELL_KNOWN_TYPES.PAGE) {
126
- const rawSections = props.sections;
127
- const resolvedSections = await resolveValue(rawSections);
128
- const sectionsList = Array.isArray(resolvedSections)
129
- ? resolvedSections
130
- : resolvedSections
131
- ? [resolvedSections]
132
- : [];
133
-
134
- const htmlParts: string[] = [];
135
- for (const section of sectionsList) {
136
- if (!section || typeof section !== "object" || Array.isArray(section)) {
137
- continue;
138
- }
139
- const sectionObj = section as Record<string, unknown>;
140
- if (!sectionObj.__resolveType) continue;
141
- const html = await renderOneSection(sectionObj);
142
- if (html) htmlParts.push(html);
143
- }
197
+ const matcherCtx = buildPreviewMatcherCtx(request);
198
+
199
+ // resolvePageSections uses the same strategy as resolveDecoPage:
200
+ // parallel section resolution, layout caching, in-flight dedup, memoization
201
+ const resolvedSections = await resolvePageSections(
202
+ props.sections,
203
+ matcherCtx,
204
+ );
205
+
206
+ // Run section loaders in parallel — benefits from layout and cacheable caches
207
+ const enrichedSections = await Promise.all(
208
+ resolvedSections.map((section) =>
209
+ runSingleSectionLoader(section, request).catch(() => section),
210
+ ),
211
+ );
212
+
213
+ // Render all sections in parallel
214
+ const htmlParts = await Promise.all(
215
+ enrichedSections.map((section) => renderResolvedSection(section)),
216
+ );
144
217
 
145
- return new Response(wrapInHtmlShell(htmlParts.join("\n")), {
218
+ return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
146
219
  status: 200,
147
220
  headers: { "Content-Type": "text/html; charset=utf-8" },
148
221
  });
@@ -163,7 +236,7 @@ export async function handleRender(request: Request): Promise<Response> {
163
236
  try {
164
237
  const resolvedProps = (await resolveValue(props)) as Record<string, unknown>;
165
238
  const { __resolveType: _, ...cleanProps } = resolvedProps;
166
- const { renderToString } = await import("react-dom/server");
239
+ const renderToString = await getRenderToString();
167
240
  const mod = await sectionLoader();
168
241
  const element = createElement(mod.default, cleanProps);
169
242
  const Wrapper = getPreviewWrapper();
package/src/cms/index.ts CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  registerMatcher,
50
50
  registerSeoSections,
51
51
  resolveDecoPage,
52
+ resolvePageSections,
52
53
  resolvePageSeoBlock,
53
54
  resolveDeferredSection,
54
55
  resolveValue,
@@ -1276,6 +1276,71 @@ export async function resolveDecoPage(
1276
1276
  };
1277
1277
  }
1278
1278
 
1279
+ /**
1280
+ * Resolve a raw sections array into ResolvedSection[] with the same
1281
+ * optimizations as resolveDecoPage: parallel resolution, layout caching,
1282
+ * in-flight dedup, and memoization.
1283
+ *
1284
+ * Unlike resolveDecoPage, this does NOT look up a page by path — the
1285
+ * caller provides the raw section array directly. Used by the admin
1286
+ * preview renderer where page data comes from the POST body.
1287
+ *
1288
+ * All sections are resolved eagerly (no deferred/async split) since
1289
+ * admin previews need the full rendered output.
1290
+ */
1291
+ export async function resolvePageSections(
1292
+ rawSectionsInput: unknown,
1293
+ matcherCtx?: MatcherContext,
1294
+ ): Promise<ResolvedSection[]> {
1295
+ ensureInitialized();
1296
+
1297
+ const ctx: MatcherContext = matcherCtx ?? {};
1298
+ const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
1299
+
1300
+ let rawSections: unknown[];
1301
+ if (Array.isArray(rawSectionsInput)) {
1302
+ rawSections = rawSectionsInput;
1303
+ } else {
1304
+ rawSections = await resolveSectionsList(rawSectionsInput, rctx);
1305
+ }
1306
+
1307
+ const eagerResults: Promise<ResolvedSection[]>[] = [];
1308
+
1309
+ for (const section of rawSections) {
1310
+ const promise = (async (): Promise<ResolvedSection[]> => {
1311
+ try {
1312
+ const layoutKey = isRawSectionLayout(section);
1313
+
1314
+ if (layoutKey) {
1315
+ const cached = getCachedResolvedLayout(layoutKey);
1316
+ if (cached) return cached;
1317
+
1318
+ const inflight = resolvedLayoutInflight.get(layoutKey);
1319
+ if (inflight) return inflight;
1320
+
1321
+ const p = resolveRawSection(section, rctx).then((results) => {
1322
+ setCachedResolvedLayout(layoutKey, results);
1323
+ return results;
1324
+ });
1325
+ resolvedLayoutInflight.set(layoutKey, p);
1326
+ p.finally(() => resolvedLayoutInflight.delete(layoutKey));
1327
+ return p;
1328
+ }
1329
+
1330
+ return resolveRawSection(section, rctx);
1331
+ } catch (e) {
1332
+ onResolveError(e, "section", "Preview section resolution");
1333
+ return [];
1334
+ }
1335
+ })();
1336
+
1337
+ eagerResults.push(promise);
1338
+ }
1339
+
1340
+ const allResults = await Promise.all(eagerResults);
1341
+ return allResults.flat();
1342
+ }
1343
+
1279
1344
  /**
1280
1345
  * Resolve a single deferred section's raw props into a fully resolved section.
1281
1346
  * Called by the loadDeferredSection server function when a section scrolls into view.