@decocms/start 2.27.0 → 2.28.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.
@@ -154,3 +154,85 @@ c = Color("#EE4F31")
154
154
  l, c_val, h = c.convert("oklch").coords()
155
155
  print(f"{l*100:.2f}% {c_val:.2f} {h:.0f}deg") # 64.42% 0.20 33deg
156
156
  ```
157
+
158
+
159
+ ## 48. Custom Color Palette + fontFamily Dropped on Migration
160
+
161
+ **Severity**: HIGH — entire pages render unstyled and Vite throws "unknown utility class" hot-overlay errors
162
+
163
+ The migrator's scaffold writes a minimal `app.css` with `@theme` containing only the gray scale + a couple of colors. Sites that defined custom palettes in `tailwind.config.ts` `theme.extend.colors` (e.g. an `als: { gray: {...}, blue: {...} }` namespace, or seasonal/brand maps) lose ALL of those tokens on migration. Same for `theme.extend.fontFamily`.
164
+
165
+ **Symptom**:
166
+ - Vite HMR overlay: `Cannot apply unknown utility class 'font-bebas-neue'` / `'bg-als-blue-500'`
167
+ - Or for CSS files using the v3 `theme()` helper: `Could not resolve value for theme function: theme(colors.als.gray.50)`
168
+ - Page DOM renders correctly but visually unstyled — no colors, default fonts.
169
+
170
+ **Detection** (run before booting dev for a fresh migration):
171
+ ```bash
172
+ # Find all <prefix>-{custom-name}-* tailwind classes used in the codebase
173
+ grep -rEo '\b(bg|text|border|fill|stroke|ring|outline|divide|placeholder|caret|accent|shadow|from|to|via)-[a-z]+-[a-z-]+(-[0-9]+)?\b' src/ \
174
+ | awk -F: '{print $2}' | sort -u
175
+
176
+ # Find theme() calls in CSS that need v4 vars
177
+ grep -rE 'theme\(colors\.|theme\(fontFamily\.' src/styles/
178
+ ```
179
+
180
+ Cross-reference against the original `tailwind.config.ts` `theme.extend.colors` / `theme.extend.fontFamily` keys.
181
+
182
+ **Fix** — port the missing tokens into `@theme`:
183
+
184
+ ```css
185
+ /* src/styles/app.css */
186
+ @theme {
187
+ --color-*: initial;
188
+ /* gray scale + std colors ... */
189
+
190
+ /* Custom brand palette (ported from tailwind.config.ts) */
191
+ --color-als-gray-50: #E4E4E4;
192
+ --color-als-gray-100: #BBBBBB;
193
+ /* ...etc */
194
+ --color-als-blue-500: #1C4DA1;
195
+
196
+ /* Custom fonts (ported from tailwind.config.ts) */
197
+ --font-bebas-neue: "Bebas Neue", sans-serif;
198
+ --font-bebas-neue-pro: "bebas-neue-pro", sans-serif;
199
+ --font-suisse-intl: "SuisseIntl", sans-serif;
200
+ }
201
+ ```
202
+
203
+ Tailwind v4 auto-generates `bg-als-blue-500`, `font-bebas-neue` etc. from these vars.
204
+
205
+ **For raw `theme()` calls in CSS files** — Tailwind v4's `theme()` resolver accepts the dot path but only for tokens registered under `@theme`. Easier and more idiomatic: rewrite as `var(--color-...)`:
206
+
207
+ ```css
208
+ /* v3 → v4 */
209
+ background-color: theme(colors.als.gray.50); /* old */
210
+ background-color: var(--color-als-gray-50); /* new */
211
+ ```
212
+
213
+
214
+ ## 49. `@layer components` Custom Classes Can't Be `@apply`d in v4
215
+
216
+ **Severity**: MEDIUM — Vite overlay error `Cannot apply unknown utility class 'container-pdp'`
217
+
218
+ Tailwind v4 only allows `@apply` to reference *utility classes* (built-ins or those declared with `@utility`). Custom classes declared inside `@layer components { .my-class { ... } }` are not utilities and can't be `@apply`d from elsewhere.
219
+
220
+ **Symptom**:
221
+ ```css
222
+ @layer components {
223
+ .container-pdp { @apply max-w-[1920px] md:!ml-[83px]; }
224
+ }
225
+
226
+ /* Later: */
227
+ .product-details ~ .pdt-div { @apply container-pdp w-full; }
228
+ /* ❌ "Cannot apply unknown utility class 'container-pdp'" */
229
+ ```
230
+
231
+ **Fix** — promote the helper to a `@utility` directive:
232
+ ```css
233
+ @utility container-pdp {
234
+ @apply max-w-[1920px] md:!ml-[83px] lg:!ml-[167px] ml-0;
235
+ }
236
+ ```
237
+
238
+ Then `@apply container-pdp` works, and so do variants (`hover:container-pdp`).
@@ -9,5 +9,36 @@ This file is an index. Each topic has its own focused file.
9
9
  | [jsx-migration.md](jsx-migration.md) | Preact→React JSX differences | #4–6, #11, #20–22, #41 |
10
10
  | [vtex-commerce.md](vtex-commerce.md) | VTEX loaders, cart, facets, price specs | #1, #7–8, #32, #34–36, #39 |
11
11
  | [worker-cloudflare.md](worker-cloudflare.md) | Worker entry, build, Cloudflare, npm | #9–10, #12–14, #19, #24–28, #30, #44–45 |
12
- | [css-styling.md](css-styling.md) | Tailwind v4, oklch, DaisyUI | #15, #17, #31, #37, #40, #42–43 |
12
+ | [css-styling.md](css-styling.md) | Tailwind v4, oklch, DaisyUI, custom palettes | #15, #17, #31, #37, #40, #42–43, #48–49 |
13
13
  | [admin-cms.md](admin-cms.md) | Admin routes, schema, device context | #16, #18, #23, #26, #29 |
14
+ | [vtex-commerce.md](vtex-commerce.md) | Section loader composition (`withSectionLoader`) | #50 |
15
+
16
+ ## #50 Quick Reference — Section Loader Composition
17
+
18
+ When wiring `registerSectionLoaders`, mixins (`withDevice`, `withMobile`,
19
+ `withSearchParam`) MUST be composed with the section's own `loader`
20
+ export — never replace it. The framework calls the registered entry as
21
+ THE section loader; if you register only mixins, the section's
22
+ `loader.ts` work silently never runs and the section renders empty
23
+ (or worse, downstream components crash on the missing data).
24
+
25
+ **Use `withSectionLoader` from `@decocms/start/cms` (≥ 2.28):**
26
+
27
+ ```typescript
28
+ import { compose, withDevice, withSearchParam, withSectionLoader } from "@decocms/start/cms";
29
+
30
+ registerSectionLoaders({
31
+ "site/sections/Header/Header.tsx": compose(
32
+ withDevice(),
33
+ withSearchParam(),
34
+ withSectionLoader(() => import("~/sections/Header/Header")),
35
+ ),
36
+ });
37
+ ```
38
+
39
+ `withSectionLoader` MUST be last — it sees the mixin-enriched props and
40
+ returns the merged result. The `@decocms/start@2.28+` migrator emits
41
+ this layout automatically; sites migrated with older versions need a
42
+ manual rewire (16 sections in als-tanstack — symptom was empty pages
43
+ and `Cannot read properties of undefined` cascades). Full pattern in
44
+ [vtex-commerce.md](vtex-commerce.md).
@@ -13,6 +13,7 @@ import { setBlocks } from "@decocms/start/cms/loader";
13
13
  import { setMetaData, setInvokeLoaders, setRenderShell } from "@decocms/start/admin";
14
14
  import { registerSections, registerSectionsSync, setResolvedComponent } from "@decocms/start/cms/registry";
15
15
  import { registerSectionLoaders, registerLayoutSections } from "@decocms/start/cms/sectionLoaders";
16
+ import { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "@decocms/start/cms";
16
17
  import { registerCommerceLoaders, setAsyncRenderingConfig, onBeforeResolve } from "@decocms/start/cms/resolve";
17
18
  import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
18
19
  import appCss from "./styles/app.css?url";
@@ -77,13 +78,39 @@ registerLayoutSections([
77
78
  // 4. SECTION LOADERS
78
79
  // ==========================================================================
79
80
 
80
- // Section loaders enrich CMS props with server-side data (e.g., VTEX API calls).
81
- // Only needed for sections that export `const loader`.
81
+ // Section loaders enrich CMS props with server-side data.
82
+ //
83
+ // Composition pattern — mixins inject helpers (device, isMobile,
84
+ // currentSearchParam) BEFORE the section's own loader, then the
85
+ // section's loader has the final say:
86
+ //
87
+ // compose(
88
+ // withMobile(),
89
+ // withSearchParam(),
90
+ // withSectionLoader(() => import("~/sections/Foo")), // must be last
91
+ // )
92
+ //
93
+ // withSectionLoader is a no-op if the module has no `loader` export and
94
+ // catches loader exceptions (legacy `(props, req, ctx)` signatures throw
95
+ // because we no longer pass ctx) — so a single broken loader never takes
96
+ // the page down. Errors are logged via `[withSectionLoader] section
97
+ // loader threw`.
98
+ //
99
+ // Anti-pattern (silently drops the section's loader):
100
+ //
101
+ // "site/sections/Product/SearchContainer.tsx": withSearchParam(),
102
+ // // ❌ SearchContainer's own loader (which sets `props.url`, runs
103
+ // // VTEX queries, etc.) is REPLACED by the mixin — page renders
104
+ // // with stale props.
82
105
  registerSectionLoaders({
83
- "site/sections/Product/ProductShelf.tsx": (props: any, req: Request) =>
84
- import("./components/product/ProductShelf").then((m) => m.loader(props, req)),
85
- "site/sections/Product/SearchResult.tsx": (props: any, req: Request) =>
86
- import("./components/search/SearchResult").then((m) => m.loader(props, req)),
106
+ "site/sections/Product/ProductShelf.tsx": withSectionLoader(
107
+ () => import("./components/product/ProductShelf"),
108
+ ),
109
+ "site/sections/Product/SearchResult.tsx": compose(
110
+ withMobile(),
111
+ withSearchParam(),
112
+ withSectionLoader(() => import("./components/search/SearchResult")),
113
+ ),
87
114
  // ... add for each section that has `export const loader`
88
115
  });
89
116
 
@@ -1589,3 +1589,118 @@ Tested via Playwright (cursor-ide-browser MCP) on `https://baggagio-tanstack.dec
1589
1589
  - **Preview deploys via `wrangler versions upload --preview-alias` are cheap, fast (90 s), and PR-scoped.** Used `https://pr-N-baggagio-tanstack.deco-cx.workers.dev` to validate cumulative state BEFORE merging. Should be the default validation step for any consolidation PR.
1590
1590
  - **The canonical Picture component's per-source `<link rel="preload" as="image" media="...">` injection is a real LCP win** — but it only triggers when `<Picture preload={true}>` is set on the call site. Baggagio's `BannerCarousel.tsx` already passes `preload={lcp}` from the CMS config; the local Picture.tsx wrapper just didn't honor it. Migration to canonical IS a perf upgrade, not just a code-cleanup.
1591
1591
  - **Canary-driven validation matters even when the changes are mechanical.** I had high confidence the cumulative state would work (typecheck + build clean), but the live test is what surfaced the "PR-B5 actually emits preload links now" finding. Without the canary loop the perf delta would have been invisible.
1592
+
1593
+ ### Wave 17 (2026-05-02 — als clean migration: end-to-end first time, with discoveries) — ✅ **SHIPPED**
1594
+
1595
+ User opted to wipe `als-tanstack` and re-import `als-storefront` from
1596
+ scratch, then run our migration tooling end-to-end on a real, htmx-heavy
1597
+ site for the first time. Goal stop-point: dev server boots + homepage
1598
+ SSR returns 200. Stretch: "the right way, not the fast way" — port the
1599
+ real things, then backport the learnings.
1600
+
1601
+ #### What landed on als-tanstack `main` (force-pushed, fresh history)
1602
+
1603
+ | Commit | What |
1604
+ |---|---|
1605
+ | `f1b6a11` | Import `als-storefront` baseline at origin/main `096686ab` |
1606
+ | `3af54d4` | Run `@decocms/start migrate.ts` (analyze/scaffold/transform/cleanup) |
1607
+ | `69727a0` | `npm install` + run codegens (blocks/sections/loaders/schema/routes) |
1608
+ | `85d5317` | Worker boots — homepage SSR returns HTTP 200 |
1609
+ | `2123516` | Port casaevideo CI/CD; bump deco-start `^2.27` + apps `^1.10`; rename worker |
1610
+ | `c6f9dcb` | Defensive guards + restored site-local utils (`format`, `formatPhoneNumber`, `formatStatusName`) |
1611
+ | `e6a1fd8` | Rewire 16 section loaders + restore Tailwind v4 theme tokens (`als` palette + custom fonts) |
1612
+
1613
+ End state: `npm run dev` boots, homepage renders 2592 DOM nodes (full
1614
+ shell, navigation, content, footer), no Invalid URL / undefined.invoke
1615
+ crashes. CSP and `clogger` warnings are non-fatal residue from the
1616
+ pre-migration site (catalog as known follow-up).
1617
+
1618
+ #### Framework changes back-ported to deco-start (this PR)
1619
+
1620
+ The als run surfaced THREE migrator regressions that previous sites
1621
+ (casaevideo / lebiscuit / baggagio) didn't trip because their section
1622
+ authors happened to wire `loader` exports differently. Three real fixes:
1623
+
1624
+ | Fix | File | Why |
1625
+ |---|---|---|
1626
+ | **`withSectionLoader` helper** | `src/cms/sectionMixins.ts` | Lets `compose(withDevice(), withSearchParam(), withSectionLoader(() => import("~/sections/Foo")))` chain mixins WITH the section's own `loader` export. Previously the migrator's template chose mixins XOR own-loader and silently dropped the section's loader when both were present. |
1627
+ | **Migrator template fix** | `scripts/migrate/templates/section-loaders.ts` | Always emit `withSectionLoader(...)` last in the chain when `meta.hasLoader === true`, alongside any `withDevice / withMobile / withSearchParam` mixins. Eliminates the silent-drop bug for future migrations. |
1628
+ | **`gotcha #50` + `setup-ts.md` template + `css-styling.md` #48–#49** | `.agents/skills/deco-to-tanstack-migration/` | Documents both the section-loader composition pattern AND the Tailwind v4 custom-palette / `@layer components → @utility` migration pitfalls discovered during als CSS restoration. |
1629
+
1630
+ `withSectionLoader` is defensive by design — if the module has no
1631
+ `loader`, returns props unchanged; if the loader throws (e.g. legacy
1632
+ `(props, req, ctx)` signature with `ctx === undefined`), logs once via
1633
+ `[withSectionLoader] section loader threw` and returns the original
1634
+ props. One broken section never takes the page down.
1635
+
1636
+ #### Wave 17 — discoveries (added to gotcha catalog)
1637
+
1638
+ - **The migrator template was XOR-ing mixins vs section loaders.** This
1639
+ is a class of bug, not just one section. Across als 16 sections were
1640
+ affected. Detection on a fresh migration is hard because everything
1641
+ builds and SSR renders — sections just silently drop their data.
1642
+ Symptoms: empty product carousels, `Cannot read properties of
1643
+ undefined (reading 'X')` cascades from downstream components that
1644
+ expected the loader's data. Now detected at template-generation time
1645
+ by always composing both.
1646
+ - **Tailwind v4 `@theme` token loss.** The migrator's scaffold writes a
1647
+ minimal `app.css` with grays + base colors only. Sites with custom
1648
+ brand palettes in `tailwind.config.ts theme.extend.colors` (als had
1649
+ `als: { gray, blue, red, ... }` namespace) lose ALL of those tokens.
1650
+ Symptom: Vite HMR overlay `Cannot apply unknown utility class
1651
+ 'font-bebas-neue' / 'bg-als-blue-500'`, page DOM correct but visually
1652
+ unstyled. Plus a v4-specific second hop: `theme(colors.als.gray.50)`
1653
+ in `.css` files no longer resolves — must rewrite as
1654
+ `var(--color-als-gray-50)`. Plus `@layer components` custom classes
1655
+ (`.container-pdp`) can't be `@apply`d in v4 — must promote to
1656
+ `@utility`. All three documented in [css-styling.md #48–49](.agents/skills/deco-to-tanstack-migration/references/css-styling.md).
1657
+ - **Site-local format utilities should NOT be hoisted to the apps SDK.**
1658
+ The migrator's overly-aggressive import-rewriting routed
1659
+ `formatPhoneNumber` / `formatStatusName` / `capitalize` to
1660
+ `@decocms/apps/commerce/sdk/formatPrice` (which doesn't export
1661
+ them). Only true commerce primitives (price formatting, currency,
1662
+ installments) belong in the apps SDK. Site-specific text formatting
1663
+ stays site-local in `src/sdk/`. Restored als-local versions and
1664
+ fixed import paths.
1665
+ - **`HttpError` was the third common shim.** Already promoted `cn`,
1666
+ `cookie`, `encoding`, `STATUS_CODE`, `UserAgent` to `@decocms/start/sdk/`
1667
+ in Wave 15. `HttpError` joined them in [deco-start#138](https://github.com/decocms/deco-start/pull/138).
1668
+ als-tanstack ships with a temporary local shim until the next apps
1669
+ release picks up the framework export — TODO is checked into
1670
+ `src/lib/http-utils.ts`.
1671
+ - **CI/CD porting from casaevideo to a new TanStack site is a 1-minute
1672
+ copy.** `deploy.yml`, `preview.yml`, `regen-blocks.yml`, plus
1673
+ `wrangler.jsonc` worker `name` rename, plus `account_id` paste from
1674
+ another site. No template needed yet — three sites is too few. Will
1675
+ template if a fourth migration needs it.
1676
+
1677
+ #### Counter-evidence the user-rule asks for
1678
+
1679
+ Going "the right way" added ~3 hours over going "the fast way" (the
1680
+ fast way was: defer the 16 section-loader rewiring, accept empty
1681
+ shelves, ship the boot SSR-200 commit and stop). The fast way would
1682
+ have hidden two of the three discoveries above, because:
1683
+
1684
+ 1. The migrator template fix only became obvious AFTER manually
1685
+ rewiring 16 sections by hand and noticing the pattern. If we had
1686
+ stopped at boot, the next site to migrate would have hit the same
1687
+ silent-drop bug.
1688
+ 2. The Tailwind v4 token-loss issue was only visible after the page
1689
+ rendered enough DOM to see "this should be branded." Boot
1690
+ verification (HTTP 200) would have passed without it.
1691
+
1692
+ So: 3 hours of "right way" produced one framework helper, one migrator
1693
+ fix, three documented gotchas, and a working canary site. The next
1694
+ htmx-heavy migration starts with these problems already solved. Net
1695
+ positive.
1696
+
1697
+ #### What this PR does NOT do (deliberately)
1698
+
1699
+ - Migrate als's htmx surface to React (deferred per Wave 14 plan; the
1700
+ codemod handled the mechanical 47% — the rest is per-component
1701
+ product work and depends on des-system decisions for things like
1702
+ filter sidebars + minicart drawer animation)
1703
+ - Validate als visually against production (visual-parity is a Phase 5+
1704
+ task; we're at Phase 4 dev-boots)
1705
+ - Ship `HttpError` consumption in als or apps — als has a local shim,
1706
+ apps will pick up the framework export on next release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.27.0",
3
+ "version": "2.28.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",
@@ -18,6 +18,12 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import {
22
+ blockHasPath,
23
+ type Candidate,
24
+ decodeBlockNameWithPasses,
25
+ mergeCandidates,
26
+ } from "./lib/blocks-dedupe";
21
27
 
22
28
  const args = process.argv.slice(2);
23
29
  function arg(name: string, fallback: string): string {
@@ -29,20 +35,6 @@ const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"))
29
35
  const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts"));
30
36
  const jsonFile = outFile.replace(/\.ts$/, ".json");
31
37
 
32
- function decodeBlockName(filename: string): string {
33
- let name = filename.replace(/\.json$/, "");
34
- while (name.includes("%")) {
35
- try {
36
- const next = decodeURIComponent(name);
37
- if (next === name) break;
38
- name = next;
39
- } catch {
40
- break; // literal % in the decoded name — nothing left to decode
41
- }
42
- }
43
- return name;
44
- }
45
-
46
38
  const TS_STUB = [
47
39
  "// Auto-generated — thin wrapper around blocks.gen.json.",
48
40
  "// The Vite plugin replaces this at load time with JSON.parse(...).",
@@ -62,30 +54,53 @@ if (!fs.existsSync(blocksDir)) {
62
54
 
63
55
  const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
64
56
 
65
- // Deduplicate: when multiple files decode to the same key, prefer the one
66
- // with actual content (largest file size wins over empty {} stubs).
67
- const blockFiles: Record<string, string> = {};
57
+ // Read each file into a Candidate, then let the dedupe lib pick the winner
58
+ // per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for
59
+ // the priority order and the rationale behind it (TL;DR: never use file size,
60
+ // don't trust mtime alone in CI clones).
61
+ const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
68
62
  for (const file of files) {
69
- const name = decodeBlockName(file);
70
- if (blockFiles[name]) {
71
- const existingSize = fs.statSync(path.join(blocksDir, blockFiles[name])).size;
72
- const newSize = fs.statSync(path.join(blocksDir, file)).size;
73
- if (newSize > existingSize) {
74
- blockFiles[name] = file;
75
- }
63
+ const { name, passes } = decodeBlockNameWithPasses(file);
64
+ const fp = path.join(blocksDir, file);
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
68
+ } catch (e) {
69
+ console.warn(`Failed to parse ${file}:`, e);
76
70
  continue;
77
71
  }
78
- blockFiles[name] = file;
72
+ candidatesWithKeys.push({
73
+ key: name,
74
+ candidate: {
75
+ file,
76
+ passes,
77
+ mtimeMs: fs.statSync(fp).mtimeMs,
78
+ hasPath: blockHasPath(parsed),
79
+ parsed,
80
+ },
81
+ });
79
82
  }
80
83
 
81
- const blocks: Record<string, unknown> = {};
82
- for (const [name, file] of Object.entries(blockFiles)) {
83
- try {
84
- const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
85
- blocks[name] = JSON.parse(content);
86
- } catch (e) {
87
- console.warn(`Failed to parse ${file}:`, e);
84
+ const { winners, collisions } = mergeCandidates(candidatesWithKeys);
85
+
86
+ if (collisions.length > 0) {
87
+ console.warn(
88
+ `Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`,
89
+ );
90
+ for (const c of collisions) {
91
+ const losers = c.files.filter((f) => f !== c.winner);
92
+ console.warn(` - ${c.key}`);
93
+ console.warn(` winner: ${c.winner}`);
94
+ for (const l of losers) console.warn(` ignore: ${l}`);
88
95
  }
96
+ console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing");
97
+ console.warn(" different filename encodings for the same logical key. Delete the");
98
+ console.warn(" stale file(s) listed under 'ignore' to silence this warning.");
99
+ }
100
+
101
+ const blocks: Record<string, unknown> = {};
102
+ for (const [name, c] of Object.entries(winners)) {
103
+ blocks[name] = c.parsed;
89
104
  }
90
105
 
91
106
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ blockHasPath,
4
+ type Candidate,
5
+ decodeBlockName,
6
+ decodeBlockNameWithPasses,
7
+ mergeCandidates,
8
+ pickWinner,
9
+ } from "./blocks-dedupe";
10
+
11
+ const cand = (overrides: Partial<Candidate> & { file: string }): Candidate => ({
12
+ passes: 0,
13
+ mtimeMs: 0,
14
+ hasPath: true,
15
+ parsed: { path: "/" },
16
+ ...overrides,
17
+ });
18
+
19
+ describe("decodeBlockNameWithPasses", () => {
20
+ it("returns the literal key with a 0 pass count when filename has no encoding", () => {
21
+ expect(decodeBlockNameWithPasses("Header.json")).toEqual({ name: "Header", passes: 0 });
22
+ });
23
+
24
+ it("decodes a single layer of URL encoding", () => {
25
+ expect(decodeBlockNameWithPasses("pages-Home%20-%20LB-618509.json")).toEqual({
26
+ name: "pages-Home - LB-618509",
27
+ passes: 1,
28
+ });
29
+ });
30
+
31
+ it("decodes the bot's double-encoded scheme through to the literal key", () => {
32
+ // Bot encodes the raw prod URL-encoded key once: `encodeURIComponent("pages-Home%20-%20LB-618509")`.
33
+ expect(decodeBlockNameWithPasses("pages-Home%2520-%2520LB-618509.json")).toEqual({
34
+ name: "pages-Home - LB-618509",
35
+ passes: 2,
36
+ });
37
+ });
38
+
39
+ it("stops decoding when a literal % survives a round", () => {
40
+ // `%` alone isn't valid encoding — the loop catches the throw and stops.
41
+ expect(decodeBlockNameWithPasses("weird%percent.json")).toEqual({
42
+ name: "weird%percent",
43
+ passes: 0,
44
+ });
45
+ });
46
+ });
47
+
48
+ describe("decodeBlockName", () => {
49
+ it("matches decodeBlockNameWithPasses on the name", () => {
50
+ expect(decodeBlockName("pages-Home%2520-%2520LB-618509.json")).toBe("pages-Home - LB-618509");
51
+ });
52
+ });
53
+
54
+ describe("blockHasPath", () => {
55
+ it("returns true for live page blocks", () => {
56
+ expect(blockHasPath({ path: "/", sections: [] })).toBe(true);
57
+ });
58
+
59
+ it("returns false when path is null (zombie entry)", () => {
60
+ expect(blockHasPath({ path: null, sections: [] })).toBe(false);
61
+ });
62
+
63
+ it("returns false when path is missing", () => {
64
+ expect(blockHasPath({ sections: [] })).toBe(false);
65
+ });
66
+
67
+ it("returns false for empty path strings", () => {
68
+ expect(blockHasPath({ path: "" })).toBe(false);
69
+ });
70
+
71
+ it("returns false for non-objects", () => {
72
+ expect(blockHasPath(null)).toBe(false);
73
+ expect(blockHasPath("/")).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("pickWinner", () => {
78
+ it("prefers a candidate with a real path over a zombie", () => {
79
+ const live = cand({ file: "live.json", hasPath: true });
80
+ const zombie = cand({ file: "zombie.json", hasPath: false, parsed: { path: null } });
81
+ expect(pickWinner(live, zombie)).toBe(live);
82
+ expect(pickWinner(zombie, live)).toBe(live);
83
+ });
84
+
85
+ it("prefers higher decode-pass count when path-status matches", () => {
86
+ // The lebiscuit reproduction case: a stale single-encoded leftover with a
87
+ // newer mtime and larger size loses to the bot's double-encoded fresh file.
88
+ const stale = cand({
89
+ file: "pages-Home%20-%20LB-618509.json",
90
+ passes: 1,
91
+ mtimeMs: 2_000_000,
92
+ });
93
+ const fresh = cand({
94
+ file: "pages-Home%2520-%2520LB-618509.json",
95
+ passes: 2,
96
+ mtimeMs: 1_000_000,
97
+ });
98
+ expect(pickWinner(stale, fresh)).toBe(fresh);
99
+ });
100
+
101
+ it("falls through to mtime when pass count ties", () => {
102
+ const older = cand({ file: "a.json", passes: 1, mtimeMs: 1 });
103
+ const newer = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
104
+ expect(pickWinner(older, newer)).toBe(newer);
105
+ });
106
+
107
+ it("falls through to lex filename when everything else ties", () => {
108
+ const a = cand({ file: "a.json", passes: 0, mtimeMs: 5 });
109
+ const b = cand({ file: "b.json", passes: 0, mtimeMs: 5 });
110
+ expect(pickWinner(a, b)).toBe(a);
111
+ expect(pickWinner(b, a)).toBe(a);
112
+ });
113
+ });
114
+
115
+ describe("mergeCandidates", () => {
116
+ it("returns each candidate unchanged when there are no collisions", () => {
117
+ const a = cand({ file: "Header.json" });
118
+ const b = cand({ file: "Footer.json" });
119
+ const result = mergeCandidates([
120
+ { candidate: a, key: "Header" },
121
+ { candidate: b, key: "Footer" },
122
+ ]);
123
+ expect(result.collisions).toEqual([]);
124
+ expect(result.winners).toEqual({ Header: a, Footer: b });
125
+ });
126
+
127
+ it("records a collision and picks the winner", () => {
128
+ const stale = cand({ file: "pages-Home%20-%20LB-618509.json", passes: 1, mtimeMs: 5 });
129
+ const fresh = cand({ file: "pages-Home%2520-%2520LB-618509.json", passes: 2, mtimeMs: 1 });
130
+ const result = mergeCandidates([
131
+ { candidate: stale, key: "pages-Home - LB-618509" },
132
+ { candidate: fresh, key: "pages-Home - LB-618509" },
133
+ ]);
134
+ expect(result.winners["pages-Home - LB-618509"]).toBe(fresh);
135
+ expect(result.collisions).toEqual([
136
+ {
137
+ key: "pages-Home - LB-618509",
138
+ files: ["pages-Home%20-%20LB-618509.json", "pages-Home%2520-%2520LB-618509.json"],
139
+ winner: "pages-Home%2520-%2520LB-618509.json",
140
+ },
141
+ ]);
142
+ });
143
+
144
+ it("collapses three-way collisions into one record without dropping the winner", () => {
145
+ const a = cand({ file: "a.json", passes: 0, mtimeMs: 1 });
146
+ const b = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
147
+ const c = cand({ file: "c.json", passes: 2, mtimeMs: 3 });
148
+ const result = mergeCandidates([
149
+ { candidate: a, key: "k" },
150
+ { candidate: b, key: "k" },
151
+ { candidate: c, key: "k" },
152
+ ]);
153
+ expect(result.winners.k).toBe(c);
154
+ expect(result.collisions).toHaveLength(1);
155
+ expect(result.collisions[0].winner).toBe("c.json");
156
+ // a, b, and c should all be tracked (a and b as ignored, c as winner).
157
+ expect(new Set(result.collisions[0].files)).toEqual(new Set(["a.json", "b.json", "c.json"]));
158
+ });
159
+
160
+ it("prefers the live page over a zombie even when zombie has more passes", () => {
161
+ const livePlain = cand({
162
+ file: "Home.json",
163
+ passes: 0,
164
+ hasPath: true,
165
+ parsed: { path: "/" },
166
+ });
167
+ const zombieEncoded = cand({
168
+ file: "Home%2520.json",
169
+ passes: 2,
170
+ hasPath: false,
171
+ parsed: { path: null },
172
+ });
173
+ const result = mergeCandidates([
174
+ { candidate: zombieEncoded, key: "Home" },
175
+ { candidate: livePlain, key: "Home" },
176
+ ]);
177
+ expect(result.winners.Home).toBe(livePlain);
178
+ });
179
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pure helpers used by `generate-blocks.ts` to choose between multiple files
3
+ * that decode to the same logical CMS block key.
4
+ *
5
+ * Background: the live decofile snapshot lives under `.deco/blocks/`, with
6
+ * one file per block. The filename is `encodeURIComponent(<rawProdKey>) +
7
+ * ".json"`. The Deco admin sometimes serves URL-encoded keys (e.g.
8
+ * `pages-Home%20-%20LB-618509`), so a single block can land on disk with
9
+ * different filenames depending on which writer produced it:
10
+ *
11
+ * - The `deco-sync-bot` (CI) encodes the raw prod key as-is, producing
12
+ * `pages-Home%2520-%2520LB-618509.json` (two decode passes back to the
13
+ * literal key).
14
+ * - The legacy manual `sync-decofile.ts` decoded keys to literal first,
15
+ * so it wrote `pages-Home%20-%20LB-618509.json` (one decode pass).
16
+ *
17
+ * Both files decode to the same logical key, so the block generator must
18
+ * pick one. Picking by file size is wrong (a shrunk live page gives a
19
+ * smaller JSON than the stale older snapshot, so size silently prefers
20
+ * stale); picking by mtime alone is wrong (fresh `git clone` writes all
21
+ * files with the clone time and erases temporal ordering).
22
+ */
23
+
24
+ export interface Candidate {
25
+ file: string;
26
+ passes: number;
27
+ mtimeMs: number;
28
+ hasPath: boolean;
29
+ parsed: unknown;
30
+ }
31
+
32
+ /**
33
+ * Repeatedly URL-decode the basename of `filename` until no `%` sequence
34
+ * remains. Returns the fully-decoded canonical key plus the number of
35
+ * decode rounds it took. Higher pass count = the writer encoded a key
36
+ * that itself contained `%XX` sequences = bot scheme. See module-level
37
+ * comment for why this matters.
38
+ */
39
+ export function decodeBlockNameWithPasses(filename: string): {
40
+ name: string;
41
+ passes: number;
42
+ } {
43
+ let name = filename.replace(/\.json$/, "");
44
+ let passes = 0;
45
+ while (name.includes("%")) {
46
+ try {
47
+ const next = decodeURIComponent(name);
48
+ if (next === name) break;
49
+ name = next;
50
+ passes++;
51
+ } catch {
52
+ break;
53
+ }
54
+ }
55
+ return { name, passes };
56
+ }
57
+
58
+ export function decodeBlockName(filename: string): string {
59
+ return decodeBlockNameWithPasses(filename).name;
60
+ }
61
+
62
+ /**
63
+ * Tie-break two candidates that decode to the same key. Priority:
64
+ * 1. Block has a non-null `path` — beats zombie/orphan entries.
65
+ * 2. More decode passes — bot's "encode raw prod key"
66
+ * scheme wins over legacy
67
+ * "decode-then-encode" leftovers
68
+ * when prod uses URL-encoded keys
69
+ * (the only case that collides).
70
+ * 3. Newer mtime — last-write-wins for same scheme.
71
+ * 4. Lexicographic filename — deterministic last resort.
72
+ */
73
+ export function pickWinner(a: Candidate, b: Candidate): Candidate {
74
+ if (a.hasPath !== b.hasPath) return a.hasPath ? a : b;
75
+ if (a.passes !== b.passes) return a.passes > b.passes ? a : b;
76
+ if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs > b.mtimeMs ? a : b;
77
+ return a.file < b.file ? a : b;
78
+ }
79
+
80
+ /** True iff a parsed block JSON looks like a live page (non-empty `.path`). */
81
+ export function blockHasPath(parsed: unknown): boolean {
82
+ return (
83
+ typeof parsed === "object" &&
84
+ parsed !== null &&
85
+ "path" in parsed &&
86
+ typeof (parsed as { path?: unknown }).path === "string" &&
87
+ (parsed as { path: string }).path.length > 0
88
+ );
89
+ }
90
+
91
+ export interface CollisionRecord {
92
+ key: string;
93
+ files: string[];
94
+ winner: string;
95
+ }
96
+
97
+ export interface MergeResult {
98
+ winners: Record<string, Candidate>;
99
+ collisions: CollisionRecord[];
100
+ }
101
+
102
+ /**
103
+ * Reduce a list of candidates into one winner per decoded key, recording
104
+ * every collision so the caller can surface it as a build warning.
105
+ */
106
+ export function mergeCandidates(
107
+ candidates: Array<{ candidate: Candidate; key: string }>,
108
+ ): MergeResult {
109
+ const winners: Record<string, Candidate> = {};
110
+ // Track every file that decoded to a given key so three-way (and beyond)
111
+ // collisions don't lose the eventual winner from the file list.
112
+ const filesByKey: Record<string, string[]> = {};
113
+ for (const { candidate, key } of candidates) {
114
+ if (!filesByKey[key]) filesByKey[key] = [];
115
+ const list = filesByKey[key];
116
+ if (!list.includes(candidate.file)) list.push(candidate.file);
117
+
118
+ const existing = winners[key];
119
+ winners[key] = existing ? pickWinner(existing, candidate) : candidate;
120
+ }
121
+
122
+ const collisions: CollisionRecord[] = [];
123
+ for (const [key, files] of Object.entries(filesByKey)) {
124
+ if (files.length < 2) continue;
125
+ collisions.push({ key, files, winner: winners[key].file });
126
+ }
127
+ return { winners, collisions };
128
+ }
@@ -69,6 +69,7 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
69
69
  lines.push(` withDevice,`);
70
70
  lines.push(` withMobile,`);
71
71
  lines.push(` withSearchParam,`);
72
+ lines.push(` withSectionLoader,`);
72
73
  lines.push(` compose,`);
73
74
  lines.push(`} from "@decocms/start/cms";`);
74
75
 
@@ -117,20 +118,36 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
117
118
  for (const meta of ctx.sectionMetas) {
118
119
  if (!meta.isHeader || !meta.hasLoader) continue;
119
120
  const sectionKey = `site/${meta.path}`;
120
- entries.push(` // Header: device + search param`);
121
+ const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
122
+ entries.push(` // Header: device + search param + section's own loader`);
121
123
  entries.push(` "${sectionKey}": async (props, req) => ({`);
122
- entries.push(` ...(await compose(withDevice(), withSearchParam())(props, req)),`);
124
+ entries.push(` ...(await compose(`);
125
+ entries.push(` withDevice(),`);
126
+ entries.push(` withSearchParam(),`);
127
+ entries.push(` withSectionLoader(() => import("${importPath}")),`);
128
+ entries.push(` )(props, req)),`);
123
129
  entries.push(` userName: "",`);
124
130
  entries.push(` }),`);
125
131
  }
126
132
 
127
- // ---------- Device/mobile sections ----------
133
+ // ---------- Device/mobile/url + own-loader composition ----------
134
+ //
135
+ // Rule of thumb: if a section exports its own `loader`, ALWAYS run it.
136
+ // Mixins (withDevice/withMobile/withSearchParam) are composed BEFORE the
137
+ // section loader so they can inject device/search-param props the section
138
+ // loader may read; the section loader has the final say over what is
139
+ // returned.
140
+ //
141
+ // The previous template chose mixins XOR own-loader and silently dropped
142
+ // the section's loader when both were present — see als-tanstack
143
+ // SearchContainerV2 SSR regression.
128
144
  for (const meta of ctx.sectionMetas) {
129
145
  if (meta.isHeader || meta.isAccountSection || meta.isStatusOnly) continue;
130
146
  // Skip sections with no loader AND no device needs
131
147
  if (!meta.hasLoader && !meta.loaderUsesDevice) continue;
132
148
  const sectionKey = `site/${meta.path}`;
133
149
  const basename = meta.path.split("/").pop()?.replace(/\.\w+$/, "") || "";
150
+ const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
134
151
 
135
152
  // Skip sections handled specially below
136
153
  const specialSections = [
@@ -141,27 +158,22 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
141
158
  ];
142
159
  if (specialSections.includes(basename)) continue;
143
160
 
144
- if (meta.loaderUsesDevice && meta.loaderUsesUrl) {
145
- const deviceMixin = meta.usesMobileBoolean ? "withMobile()" : "withDevice()";
146
- entries.push(` "${sectionKey}": compose(${deviceMixin}, withSearchParam()),`);
147
- } else if (meta.loaderUsesDevice) {
148
- if (meta.usesMobileBoolean) {
149
- entries.push(` "${sectionKey}": withMobile(),`);
150
- } else {
151
- entries.push(` "${sectionKey}": withDevice(),`);
152
- }
153
- } else if (meta.loaderUsesUrl) {
154
- entries.push(` "${sectionKey}": withSearchParam(),`);
155
- } else if (meta.hasLoader) {
156
- const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
157
- entries.push(` "${sectionKey}": async (props: any, req: Request) => {`);
158
- entries.push(` const mod = await import("${importPath}");`);
159
- // Cast to any: legacy Fresh/Deno section loaders are typed `(props, req, ctx)`.
160
- // We invoke with 2 args; any ctx-dependent code path inside the loader will throw
161
- // at runtime and must be refactored — the migration phase-transform flags these.
162
- entries.push(` if (typeof mod.loader === "function") return (mod.loader as any)(props, req);`);
163
- entries.push(` return props;`);
164
- entries.push(` },`);
161
+ const mixins: string[] = [];
162
+ if (meta.loaderUsesDevice) {
163
+ mixins.push(meta.usesMobileBoolean ? "withMobile()" : "withDevice()");
164
+ }
165
+ if (meta.loaderUsesUrl) mixins.push("withSearchParam()");
166
+ if (meta.hasLoader) {
167
+ mixins.push(`withSectionLoader(() => import("${importPath}"))`);
168
+ }
169
+
170
+ if (mixins.length === 0) continue;
171
+ if (mixins.length === 1) {
172
+ entries.push(` "${sectionKey}": ${mixins[0]},`);
173
+ } else {
174
+ entries.push(` "${sectionKey}": compose(`);
175
+ for (const m of mixins) entries.push(` ${m},`);
176
+ entries.push(` ),`);
165
177
  }
166
178
  }
167
179
 
package/src/cms/index.ts CHANGED
@@ -75,6 +75,12 @@ export {
75
75
  runSectionLoaders,
76
76
  runSingleSectionLoader,
77
77
  } from "./sectionLoaders";
78
- export { compose, withDevice, withMobile, withSearchParam } from "./sectionMixins";
78
+ export {
79
+ compose,
80
+ withDevice,
81
+ withMobile,
82
+ withSearchParam,
83
+ withSectionLoader,
84
+ } from "./sectionMixins";
79
85
  export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
80
86
  export { applySectionConventions } from "./applySectionConventions";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Section mixins — focused on `withSectionLoader` since the device/mobile
3
+ * mixins are exercised by sectionLoaders integration tests already.
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { compose, withSectionLoader, withSearchParam } from "./sectionMixins";
7
+
8
+ const makeReq = (url = "https://store.example/foo?q=hello") =>
9
+ new Request(url, { headers: { "user-agent": "vitest" } });
10
+
11
+ describe("withSectionLoader", () => {
12
+ it("invokes the section's exported loader and returns its result", async () => {
13
+ const loader = vi.fn(async (props: Record<string, unknown>) => ({
14
+ ...props,
15
+ url: "https://store.example/foo?q=hello",
16
+ enriched: true,
17
+ }));
18
+
19
+ const mixin = withSectionLoader(async () => ({ loader }));
20
+ const result = await mixin({ original: "value" }, makeReq());
21
+
22
+ expect(loader).toHaveBeenCalledTimes(1);
23
+ expect(result).toEqual({
24
+ original: "value",
25
+ url: "https://store.example/foo?q=hello",
26
+ enriched: true,
27
+ });
28
+ });
29
+
30
+ it("returns props unchanged when module has no loader export", async () => {
31
+ const mixin = withSectionLoader(async () => ({ default: () => null }));
32
+ const props = { foo: "bar" };
33
+ const result = await mixin(props, makeReq());
34
+ expect(result).toBe(props);
35
+ });
36
+
37
+ it("returns props unchanged when module is undefined / loader is not a function", async () => {
38
+ const mixin = withSectionLoader(async () => undefined);
39
+ const props = { foo: "bar" };
40
+ expect(await mixin(props, makeReq())).toBe(props);
41
+
42
+ const mixinWithBadLoader = withSectionLoader(async () => ({
43
+ loader: "not-a-function",
44
+ }));
45
+ expect(await mixinWithBadLoader(props, makeReq())).toBe(props);
46
+ });
47
+
48
+ it("swallows loader errors and returns the original props", async () => {
49
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
50
+ const mixin = withSectionLoader(async () => ({
51
+ loader: async () => {
52
+ throw new Error("boom");
53
+ },
54
+ }));
55
+ const props = { foo: "bar" };
56
+ const result = await mixin(props, makeReq());
57
+ expect(result).toBe(props);
58
+ expect(errorSpy).toHaveBeenCalledWith(
59
+ "[withSectionLoader] section loader threw:",
60
+ expect.any(Error),
61
+ );
62
+ errorSpy.mockRestore();
63
+ });
64
+
65
+ it("falls back to original props when loader returns undefined", async () => {
66
+ const mixin = withSectionLoader(async () => ({
67
+ loader: async () => undefined,
68
+ }));
69
+ const props = { foo: "bar" };
70
+ expect(await mixin(props, makeReq())).toBe(props);
71
+ });
72
+
73
+ it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
74
+ const seen: Array<Record<string, unknown>> = [];
75
+ const sectionLoader = vi.fn(
76
+ async (props: Record<string, unknown>) => {
77
+ seen.push({ ...props });
78
+ return { ...props, sectionLoaderRan: true };
79
+ },
80
+ );
81
+
82
+ const composed = compose(
83
+ withSearchParam(),
84
+ withSectionLoader(async () => ({ loader: sectionLoader })),
85
+ );
86
+
87
+ const result = await composed({ original: 1 }, makeReq());
88
+
89
+ expect(seen).toHaveLength(1);
90
+ // The section loader sees the enriched props (currentSearchParam injected by withSearchParam)
91
+ expect(seen[0]).toMatchObject({
92
+ original: 1,
93
+ currentSearchParam: "hello",
94
+ });
95
+ expect(result).toMatchObject({
96
+ original: 1,
97
+ currentSearchParam: "hello",
98
+ sectionLoaderRan: true,
99
+ });
100
+ });
101
+
102
+ it("loads the module lazily — modImport is only called on first invocation", async () => {
103
+ const modImport = vi.fn(async () => ({
104
+ loader: async (p: Record<string, unknown>) => ({ ...p, ran: true }),
105
+ }));
106
+ const mixin = withSectionLoader(modImport);
107
+ expect(modImport).not.toHaveBeenCalled();
108
+
109
+ await mixin({ a: 1 }, makeReq());
110
+ expect(modImport).toHaveBeenCalledTimes(1);
111
+
112
+ // A second invocation calls the import again — caching is the
113
+ // dynamic import's responsibility (which the runtime memoises).
114
+ await mixin({ a: 2 }, makeReq());
115
+ expect(modImport).toHaveBeenCalledTimes(2);
116
+ });
117
+ });
@@ -74,3 +74,58 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
74
74
  return result;
75
75
  };
76
76
  }
77
+
78
+ /**
79
+ * Wraps a section module's exported `loader` so it can be composed alongside
80
+ * mixins like {@link withDevice}, {@link withMobile}, {@link withSearchParam}.
81
+ *
82
+ * The `modImport` argument is a lazy factory (typically `() => import("~/sections/...")`)
83
+ * — the module is loaded on first call. If the module does not export a
84
+ * `loader`, the original `props` are returned unchanged (no-op).
85
+ *
86
+ * Why this exists: the migrator and many sites lifted from Fresh declare a
87
+ * `loader` export on the section file (often re-exported from the inner
88
+ * component). That loader sets things like `url: req.url`, runs platform
89
+ * invocations, or calls the section's domain logic. If the section is wired
90
+ * in `registerSectionLoaders` with mixin-only (e.g. `withSearchParam()`),
91
+ * the section's own loader is silently *replaced* — its work never runs and
92
+ * its returned props are dropped.
93
+ *
94
+ * Compose this helper FIRST in the chain so mixin-injected props
95
+ * (`device`, `currentSearchParam`, …) are available to the section's loader,
96
+ * then the section's loader has the final word over what is returned.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * import {
101
+ * compose,
102
+ * withMobile,
103
+ * withSearchParam,
104
+ * withSectionLoader,
105
+ * } from "@decocms/start/cms";
106
+ *
107
+ * registerSectionLoaders({
108
+ * "site/sections/Product/SearchContainerV2.tsx": compose(
109
+ * withMobile(),
110
+ * withSearchParam(),
111
+ * withSectionLoader(() => import("~/sections/Product/SearchContainerV2")),
112
+ * ),
113
+ * });
114
+ * ```
115
+ */
116
+ export function withSectionLoader(
117
+ modImport: () => Promise<unknown>,
118
+ ): SectionLoaderFn {
119
+ return async (props, req) => {
120
+ const mod = (await modImport()) as { loader?: unknown } | undefined;
121
+ const loader = mod?.loader;
122
+ if (typeof loader !== "function") return props;
123
+ try {
124
+ const result = await (loader as SectionLoaderFn)(props, req);
125
+ return result ?? props;
126
+ } catch (error) {
127
+ console.error("[withSectionLoader] section loader threw:", error);
128
+ return props;
129
+ }
130
+ };
131
+ }