@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.
- package/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +82 -0
- package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +32 -1
- package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +33 -6
- package/MIGRATION_TOOLING_PLAN.md +176 -0
- package/package.json +6 -1
- package/scripts/migrate/templates/section-loaders.ts +36 -24
- package/src/cms/index.ts +7 -1
- package/src/cms/sectionMixins.test.ts +117 -0
- package/src/cms/sectionMixins.ts +55 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +90 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/useScript.test.ts +77 -2
- package/src/sdk/useScript.ts +48 -8
|
@@ -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
|
|
81
|
-
//
|
|
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": (
|
|
84
|
-
import("./components/product/ProductShelf")
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
entries.push(` "${sectionKey}":
|
|
158
|
-
|
|
159
|
-
|
|
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 {
|
|
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
|
+
});
|