@decocms/start 2.26.0 → 2.28.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.
@@ -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
 
@@ -1528,3 +1528,179 @@ What's still ahead:
1528
1528
  - **C8 (state persistence between migration phases)**: moderate effort, value mostly in skipping `npm install` on phase-9 retries. Polish.
1529
1529
  - **`vibe-dex/*` orphan branches in apps-start**: ✅ all 5 cleaned this wave.
1530
1530
  - **Apps registry (apps-start#18 + deco-start#81)**: defer until clear consumer.
1531
+
1532
+ ### Wave 16 (2026-05-02 — baggagio as production canary, stacked-PR pitfall RECURRENCE)
1533
+
1534
+ User merged baggagio's PRs B1–B6 to use as guinea pig before applying the same patterns to casaevideo + lebiscuit (which ARE in production). Live validation found a critical fact: **only B1 (the bump) actually reached `main`**. PRs #13–#17 were all merged in GitHub UI but their merge commits ended up on the **previous PR's branch**, never on `main`.
1535
+
1536
+ #### What happened (the same pitfall as Wave 8, recurring)
1537
+
1538
+ Each PR was opened with `base = previous PR's branch`:
1539
+
1540
+ | PR | Title | base | Merge commit landed on |
1541
+ |---|---|---|---|
1542
+ | #12 | bump 2.10→2.26 + apps 1.7→1.9 | `main` | ✅ `main` |
1543
+ | #13 | drop `src/sdk/clx.ts` | `chore/bump-deco-2.26-apps-1.9` | ❌ that branch |
1544
+ | #14 | createUseSuggestions factory | `chore/drop-local-clx` | ❌ that branch |
1545
+ | #15 | canonical `relative()` | `chore/use-framework-suggestions-factory` | ❌ that branch |
1546
+ | #16 | canonical `Picture` | `refactor/use-canonical-relative-url` | ❌ that branch |
1547
+ | #17 | drop dead `useUser` stub | `refactor/use-canonical-picture` | ❌ that branch |
1548
+
1549
+ Each PR shows `state: MERGED` in GitHub. But the merge commit physically landed on each PR's source-branch tip, not on main. Result: all 5 cleanup PRs were silently orphaned.
1550
+
1551
+ Detection method that worked: file-existence check on `git show main:<deleted-file>` — `clx.ts`, `url.ts`, `Picture.tsx`, `useUser.ts` were all still present on main despite their PRs being "merged". Exists/absent is a faster signal than diff browsing.
1552
+
1553
+ #### Recovery: PR #18 — single consolidation
1554
+
1555
+ The deepest stacked branch (`chore/drop-dead-local-useuser`) cumulatively contained all 5 cleanups (B2–B6) linearly stacked on B1. Opened [`baggagio-tanstack#18`](https://github.com/deco-sites/baggagio-tanstack/pull/18) as `chore/consolidate-b2-b6-to-main` → `main`, replaying the exact contents of #13–#17 in order. Diff vs main: **59 files changed, +70 / −240, 4 files deleted**. Typecheck + build clean. Preview at `pr-18-baggagio-tanstack.deco-cx.workers.dev` rendered identical homepage / PLP / PDP / search to current main with zero new console errors. Merged to main, deploy succeeded.
1556
+
1557
+ #### Live validation post-merge (cumulative state on main)
1558
+
1559
+ Tested via Playwright (cursor-ide-browser MCP) on `https://baggagio-tanstack.deco-cx.workers.dev/`:
1560
+
1561
+ | Surface | Result | Notes |
1562
+ |---|---|---|
1563
+ | Homepage | ✅ Renders | Banner, categories, product carousel, footer all intact |
1564
+ | PLP `/s?q=mochila` | ✅ 927 produtos | Filter + sort UI present, all images load |
1565
+ | PDP `/mochila-masculina-executiva-para-notebook-horizonte/p` | ✅ Renders | Image gallery, prices, COMPRAR, frete calc, descrição all present |
1566
+ | Search suggestions endpoint | ✅ 200 | Empty `searches[]` confirmed pre-existing (matches www.bagaggio.com.br) |
1567
+ | `<picture>` HTML | ✅ 18 picture / 36 source | composable canonical pattern |
1568
+ | Console errors (filtered 3rd-party) | ✅ Same as before | `[inline-script polyfill]` + image preload warnings pre-existing on main |
1569
+
1570
+ **Bonus discovery**: PR-B5 (canonical Picture) now correctly emits `<link rel="preload" as="image" media="(max-width: 767px)" imageSrcSet="..." fetchPriority="high">` for LCP banners — a real Web Vitals improvement that was NOT visible before the consolidation because Picture.tsx (the local wrapper without preload) was still on main.
1571
+
1572
+ #### Each PR's safety verdict (for casaevideo + lebiscuit replay)
1573
+
1574
+ | PR | Status | Safe to replay? |
1575
+ |---|---|---|
1576
+ | B1 — bump 2.x → 2.26 + apps 1.x → 1.9 | ✅ | YES — zero regressions on real site |
1577
+ | B2 — drop `src/sdk/clx.ts` | ✅ | YES — pure rewrite, framework export is byte-equivalent |
1578
+ | B3 — `createUseSuggestions` factory | ✅ | YES — wiring works end-to-end (200 status, payload reaches store) |
1579
+ | B4 — canonical `relative()` with `stripSearchParams` | ✅ | YES — only affects PLPs with `?skuId=` URL params, no functional regression |
1580
+ | B5 — canonical `Picture` from apps | ✅ + bonus | YES — adds proper `<link rel="preload" as="image" media>` for LCP |
1581
+ | B6 — drop dead `src/hooks/useUser.ts` | ✅ | YES — file had 0 external imports |
1582
+
1583
+ **For casaevideo + lebiscuit**: same set of PRs is validated as safe. The replays (`C1`–`C11`, `L1`–`L11`) can proceed on production sites with confidence.
1584
+
1585
+ ### Wave 16 — discoveries
1586
+
1587
+ - **Stacked-PR pitfall recurred even after Wave 8 documented it.** The Wave 8 mitigation ("verify base is main before merging") was not enforced; the user merged B2–B6 with original stacked bases. Stronger mitigation needed: when opening a stacked PR, **default to a single consolidating PR at the end** rather than 5 separate stacked merges. Single PR is one merge button, one CI run, one deploy — not 5 chances to mis-target the base.
1588
+ - **File-existence check is the fastest "did the merge actually land on main?" probe.** Faster than reading PR-stats, faster than diffing branches. `git show main:<deleted-file> 2>&1` — empty stderr means the deletion didn't reach main.
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
+ - **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
+ - **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.26.0",
3
+ "version": "2.28.0",
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",
@@ -19,6 +19,9 @@
19
19
  "./sdk/useScript": "./src/sdk/useScript.ts",
20
20
  "./sdk/signal": "./src/sdk/signal.ts",
21
21
  "./sdk/clx": "./src/sdk/clx.ts",
22
+ "./sdk/cn": "./src/sdk/cn.ts",
23
+ "./sdk/encoding": "./src/sdk/encoding.ts",
24
+ "./sdk/http": "./src/sdk/http.ts",
22
25
  "./sdk/useSuggestions": "./src/sdk/useSuggestions.ts",
23
26
  "./sdk/retry": "./src/sdk/retry.ts",
24
27
  "./sdk/useId": "./src/sdk/useId.ts",
@@ -96,7 +99,9 @@
96
99
  },
97
100
  "dependencies": {
98
101
  "@deco-cx/warp-node": "^0.3.16",
102
+ "clsx": "^2.1.1",
99
103
  "fast-json-patch": "^3.1.0",
104
+ "tailwind-merge": "^3.3.1",
100
105
  "tsx": "^4.19.0",
101
106
  "ws": "^8.18.0"
102
107
  },
@@ -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
+ });