@decocms/start 2.25.0 → 2.27.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/SKILL.md +1 -0
- package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks-factories.md +84 -2
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +1 -0
- package/MIGRATION_TOOLING_PLAN.md +149 -5
- package/package.json +7 -1
- package/scripts/migrate/post-cleanup/rules.ts +26 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +44 -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
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
|
@@ -31,6 +31,7 @@ Phase-based playbook for converting `deco-sites/*` storefronts from Fresh/Preact
|
|
|
31
31
|
| ~/sdk/useOffer | @decocms/apps/commerce/sdk/useOffer |
|
|
32
32
|
| ~/sdk/useScript | @decocms/start/sdk/useScript |
|
|
33
33
|
| ~/sdk/signal | @decocms/start/sdk/signal |
|
|
34
|
+
| ~/sdk/useSuggestions (hand-rolled) | @decocms/start/sdk/useSuggestions → `createUseSuggestions<T>()` factory |
|
|
34
35
|
|
|
35
36
|
## Migration Phases
|
|
36
37
|
|
|
@@ -175,12 +175,94 @@ actions, custom analytics events) rather than ripping out the factory and going
|
|
|
175
175
|
|
|
176
176
|
---
|
|
177
177
|
|
|
178
|
+
## `useSuggestions` (search autocomplete) — the same pattern at framework layer
|
|
179
|
+
|
|
180
|
+
`createUseSuggestions` lives in `@decocms/start/sdk/useSuggestions`
|
|
181
|
+
(not apps), because the queue + cancel + invoke-fetch primitive is
|
|
182
|
+
not commerce-specific. It debounces and serialises calls to
|
|
183
|
+
`/deco/invoke/<__resolveType>` and exposes signal-shaped state —
|
|
184
|
+
exactly the shape both casaevideo and baggagio independently
|
|
185
|
+
invented in their site-local `src/sdk/useSuggestions.ts`.
|
|
186
|
+
|
|
187
|
+
### Site-local shim (the entire file)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// src/sdk/useSuggestions.ts
|
|
191
|
+
import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";
|
|
192
|
+
import * as Sentry from "@sentry/react";
|
|
193
|
+
import type { Suggestion } from "@decocms/apps/commerce/types";
|
|
194
|
+
|
|
195
|
+
export const { useSuggestions } = createUseSuggestions<Suggestion>({
|
|
196
|
+
onError: (err) => Sentry.captureException(err),
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The call sites stay byte-identical:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const { setQuery, payload, loading } = useSuggestions(loader);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Why a factory and not a plain hook
|
|
207
|
+
|
|
208
|
+
Same two reasons as the apps factories:
|
|
209
|
+
|
|
210
|
+
1. **State isolation per call.** Each `createUseSuggestions()`
|
|
211
|
+
instantiates a fresh `payload` / `loading` / queue tuple. Sites
|
|
212
|
+
with multiple independent suggestion streams (e.g. searchbar +
|
|
213
|
+
category jumper) each call the factory and bind their own
|
|
214
|
+
`useSuggestions`.
|
|
215
|
+
2. **Type narrowing at the boundary.** The factory takes `<T>` once;
|
|
216
|
+
the returned hook is already specialised, so callers don't re-pass
|
|
217
|
+
generics. Sites pick `Suggestion` (VTEX), `Suggestions` (Shopify),
|
|
218
|
+
or any custom shape at the factory boundary.
|
|
219
|
+
|
|
220
|
+
### What the factory owns
|
|
221
|
+
|
|
222
|
+
| Concern | Where it lives |
|
|
223
|
+
|---|---|
|
|
224
|
+
| Module-level signals (`payload`, `loading`) per stream | Factory closure |
|
|
225
|
+
| Serial in-flight queue | Factory closure |
|
|
226
|
+
| Latest-query cancel guard | Factory closure |
|
|
227
|
+
| `fetch('/deco/invoke/<resolveType>', { body: { query, …extraProps } })` | Factory |
|
|
228
|
+
| `onError(error, query)` Sentry/OTEL hook | Site (passed at instantiation) |
|
|
229
|
+
| `console.error('[useSuggestions] fetch failed:', error)` | Factory (always runs) |
|
|
230
|
+
| Suggestion payload type | Site (factory generic `<T>`) |
|
|
231
|
+
|
|
232
|
+
### Migrating off the hand-rolled hook
|
|
233
|
+
|
|
234
|
+
If your site has a 50-line `src/sdk/useSuggestions.ts` with module-level
|
|
235
|
+
`signal`s and a `latestQuery` variable, the post-cleanup audit's
|
|
236
|
+
`local-framework-duplicate` rule flags it (warn-only, since the
|
|
237
|
+
per-site type parameter and `onError` wiring need site-specific
|
|
238
|
+
decisions). The mechanical migration:
|
|
239
|
+
|
|
240
|
+
1. Replace the entire file with the 5-line factory shim above.
|
|
241
|
+
2. Pick the right `<T>` for your site:
|
|
242
|
+
- VTEX: `Suggestion` from `@decocms/apps/commerce/types`
|
|
243
|
+
- Sites with custom intelligent-search loaders: the loader's
|
|
244
|
+
payload type (e.g. `IntelligenseSearch`)
|
|
245
|
+
3. Decide on `onError` — pass `(err) => Sentry.captureException(err)`
|
|
246
|
+
if you wired Sentry; omit it otherwise. The factory always logs
|
|
247
|
+
to console after `onError` returns, so unhandled cases stay
|
|
248
|
+
visible.
|
|
249
|
+
4. Run `npm run typecheck` — call sites stay byte-identical.
|
|
250
|
+
|
|
251
|
+
The advanced `_internal` field on the factory return value exposes
|
|
252
|
+
the raw signals + a non-React `setQuery` and a `drain()` promise.
|
|
253
|
+
Sites use it for SSR pre-fetch helpers and tests; you almost never
|
|
254
|
+
need it.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
178
258
|
## Related
|
|
179
259
|
|
|
180
260
|
- `scripts/migrate/templates/hooks.ts` — the template that emits the
|
|
181
|
-
shims
|
|
261
|
+
cart/user/wishlist shims.
|
|
182
262
|
- `apps-start/vtex/hooks/createUseCart.ts` /
|
|
183
|
-
`createUseUser.ts` / `createUseWishlist.ts` — the factories
|
|
263
|
+
`createUseUser.ts` / `createUseWishlist.ts` — the platform factories
|
|
184
264
|
themselves; each docstring is the authoritative API reference.
|
|
265
|
+
- `deco-start/src/sdk/useSuggestions.ts` — the
|
|
266
|
+
`createUseSuggestions` factory (framework layer, platform-agnostic).
|
|
185
267
|
- `references/platform-hooks/README.md` — historical reference for the
|
|
186
268
|
pre-W12 manual approach (kept for sites that haven't migrated yet).
|
|
@@ -431,6 +431,7 @@ logic, wrapped in something else) are skipped automatically.
|
|
|
431
431
|
| `src/sdk/useSendEvent.ts` | `@decocms/start/sdk/analytics` | no | Site copy uses `<E extends AnalyticsEvent>` generic; framework export is permissive. Replace 1:1 = type-safety loss. Either widen the framework first or accept the loss. |
|
|
432
432
|
| `src/matchers/location.ts` | `@decocms/start/matchers/builtins` | no | Framework's `registerBuiltinMatchers()` ships a richer location matcher (`request.cf` first, geo cookies fallback, headers fallback) plus 10 sibling matchers. Adopting changes behaviour — verify country-name lookup parity, swap `setup.ts`'s `customMatchers` entry. |
|
|
433
433
|
| `src/sdk/url.ts` | `@decocms/apps/commerce/sdk/url` | no | Site fork carries a positional `removeIdSku?: boolean` flag with hardcoded VTEX-specific keys. Canonical apps export uses `{ stripSearchParams: string[] }` (`@decocms/apps@1.9+`). Rewrite imports + each `relative(url, true)` call site → `relative(url, { stripSearchParams: ["idsku", "skuId"] })`, then delete the file. Auto-fix is gated because the call-site rewrite needs JSX/TS-aware transformation, not pure import rewrite. |
|
|
434
|
+
| `src/sdk/useSuggestions.ts` | `@decocms/start/sdk/useSuggestions` | no | Hand-rolled hook with module-level signal + serial-queue + latestQuery cancel pattern. Both casaevideo and baggagio independently invented the exact same shape, so the canonical is now a `createUseSuggestions<T>` factory (`@decocms/start@2.25+`). Sites replace the file with a 5-line factory shim — see `references/platform-hooks-factories.md` § useSuggestions. Auto-fix is gated because the per-site type parameter (`Suggestion` vs `IntelligenseSearch` vs site-specific) and `onError` wiring need site-specific decisions. |
|
|
434
435
|
|
|
435
436
|
### Adding a new entry
|
|
436
437
|
|
|
@@ -1205,11 +1205,6 @@ copy-paste regression on any future site gets caught automatically.
|
|
|
1205
1205
|
|
|
1206
1206
|
**Still in the cross-site backlog (sequenced behind 15-B-1):**
|
|
1207
1207
|
|
|
1208
|
-
- **15-B-2** — `useSuggestions` framework helper (new export in
|
|
1209
|
-
`@decocms/start/sdk` typed by `Resolved<T>`, optional Sentry
|
|
1210
|
-
hook). Sites adopt incrementally; once 2+ adopt, add a registry
|
|
1211
|
-
entry pointing the legacy hand-rolled implementations at the
|
|
1212
|
-
canonical via `local-framework-duplicate`.
|
|
1213
1208
|
- **15-B-3** — `useOffer` factory (D4 candidate; needs design pass
|
|
1214
1209
|
for PIX/installment plugin slots).
|
|
1215
1210
|
- **15-B-4** — `Picture` API unification (breaking; needs a
|
|
@@ -1286,6 +1281,94 @@ the released canonical. The skill doc explicitly version-pins the
|
|
|
1286
1281
|
canonical (`@decocms/apps@1.9+`) so engineers reading the audit
|
|
1287
1282
|
output know whether they need to bump apps before adopting.
|
|
1288
1283
|
|
|
1284
|
+
### Wave 15-B-2 (canonical `useSuggestions` factory + audit registry entry) — 🟡 **IN FLIGHT**
|
|
1285
|
+
|
|
1286
|
+
`useSuggestions` was the next D4 candidate after the `clx` /
|
|
1287
|
+
`useSendEvent` / `location-matcher` audit. Both casaevideo and
|
|
1288
|
+
baggagio independently invented the *exact same* shape — module-level
|
|
1289
|
+
signal for payload + loading, FIFO promise queue, "is this still the
|
|
1290
|
+
latest query?" cancel guard, post to `/deco/invoke/<__resolveType>`.
|
|
1291
|
+
Differences were minor (Sentry hook in casaevideo, the cancel guard
|
|
1292
|
+
in `finally` only in baggagio's version — actually the correct
|
|
1293
|
+
behaviour, casaevideo's omission is a latent bug).
|
|
1294
|
+
|
|
1295
|
+
Verified state (2026-05-01 grep):
|
|
1296
|
+
- casaevideo `src/sdk/useSuggestions.ts` — 58 LOC, typed via local
|
|
1297
|
+
`IntelligenseSearch`, Sentry-wrapped errors, missing latest-query
|
|
1298
|
+
guard in `finally`
|
|
1299
|
+
- baggagio `src/sdk/useSuggestions.ts` — 55 LOC, typed via VTEX
|
|
1300
|
+
`Suggestion`, no observability, has the latest-query guard
|
|
1301
|
+
(correct behaviour)
|
|
1302
|
+
- Single call site each (`Searchbar`/`Searchbar/Form`)
|
|
1303
|
+
|
|
1304
|
+
**Decision: framework, not apps.** The hook is a debounce/cancel/
|
|
1305
|
+
coalesce primitive; the commerce-flavoured usage is incidental.
|
|
1306
|
+
Apps depends on framework, not the other way around — putting it in
|
|
1307
|
+
`@decocms/start/sdk` is the right layering.
|
|
1308
|
+
|
|
1309
|
+
**Decision: factory pattern.** Matches `createUseCart` /
|
|
1310
|
+
`createUseUser` / `createUseWishlist` (D4 done right). State
|
|
1311
|
+
isolation per call, type narrowing at the factory boundary, sites
|
|
1312
|
+
get a 5-line shim.
|
|
1313
|
+
|
|
1314
|
+
**Shipped (one PR):**
|
|
1315
|
+
|
|
1316
|
+
41. `feat(sdk): createUseSuggestions factory + audit registry entry` 🟡 **WAITING ON CI**.
|
|
1317
|
+
- **`src/sdk/useSuggestions.ts`** (new, 158 LOC) — exports
|
|
1318
|
+
`createUseSuggestions<T>(options?)` returning
|
|
1319
|
+
`{ useSuggestions, _internal }`. Options: `onError(err, query)`
|
|
1320
|
+
Sentry/OTEL hook, `fetchImpl` for tests. The `_internal` field
|
|
1321
|
+
exposes the raw signals + a non-React `setQuery(query, loader)`
|
|
1322
|
+
and a `drain()` promise for SSR pre-fetch helpers and unit
|
|
1323
|
+
tests.
|
|
1324
|
+
- **Bug fix included**: the canonical adopts baggagio's
|
|
1325
|
+
`if (latestQuery === query) loading.value = false` guard in
|
|
1326
|
+
`finally`. casaevideo's version cleared loading
|
|
1327
|
+
unconditionally — meaning rapid keystrokes could leave the
|
|
1328
|
+
UI in an "older fetch wins" state. The factory closes that
|
|
1329
|
+
gap by default.
|
|
1330
|
+
- **`src/sdk/useSuggestions.test.ts`** (new) — 11 tests. Factory
|
|
1331
|
+
shape + isolation; happy-path fetch (correct URL, body, response
|
|
1332
|
+
mapping); loading-flag invariants; cancel guard verified by an
|
|
1333
|
+
echo-fetch mock that proves only the latest query reaches the
|
|
1334
|
+
network; serial-queue verified by a race detector that asserts
|
|
1335
|
+
`maxInflight === 1`; error path verified for `onError`
|
|
1336
|
+
forwarding, console fallback when no `onError` is wired,
|
|
1337
|
+
non-2xx responses, payload preservation across errors.
|
|
1338
|
+
- **`scripts/migrate/post-cleanup/rules.ts`** — 5th entry in
|
|
1339
|
+
`FRAMEWORK_DUPLICATES` registry for `src/sdk/useSuggestions.ts`
|
|
1340
|
+
→ `@decocms/start/sdk/useSuggestions`. Content signature
|
|
1341
|
+
anchored on the legacy hand-rolled shape (`export const
|
|
1342
|
+
useSuggestions =`, `/deco/invoke/`, `latestQuery`). Sites that
|
|
1343
|
+
already adopted the factory shim are NOT flagged — proven by
|
|
1344
|
+
a negative test case.
|
|
1345
|
+
- **`safeToAutoFix: false`** — the per-site type parameter
|
|
1346
|
+
(`Suggestion` vs `IntelligenseSearch` vs custom) and `onError`
|
|
1347
|
+
wiring need site-specific decisions, so the rule emits a
|
|
1348
|
+
detailed `fix:` recipe instead of trying to auto-rewrite.
|
|
1349
|
+
- **Skill doc updates**:
|
|
1350
|
+
- `references/platform-hooks-factories.md` — new section
|
|
1351
|
+
documenting `createUseSuggestions` (site shim, factory
|
|
1352
|
+
ownership table, migrating-off recipe).
|
|
1353
|
+
- `references/post-migration-cleanup.md` § 8 — 5th row in the
|
|
1354
|
+
registry table with version pin (`@decocms/start@2.25+`).
|
|
1355
|
+
- `SKILL.md` architecture map — adds the `~/sdk/useSuggestions
|
|
1356
|
+
(hand-rolled) → @decocms/start/sdk/useSuggestions
|
|
1357
|
+
createUseSuggestions<T>()` row.
|
|
1358
|
+
- **`package.json`** — exposes `./sdk/useSuggestions` export.
|
|
1359
|
+
- 368/368 tests pass (was 355 — +11 factory tests, +2 audit
|
|
1360
|
+
registry tests). typecheck clean. Smoke output:
|
|
1361
|
+
- baggagio: 4 findings (was 3) — clx, useSendEvent, url-relative,
|
|
1362
|
+
**use-suggestions** (new)
|
|
1363
|
+
- casaevideo: 2 findings (was 1) — location-matcher,
|
|
1364
|
+
**use-suggestions** (new)
|
|
1365
|
+
|
|
1366
|
+
**Architectural note**: `useSuggestions` is the first framework-side
|
|
1367
|
+
factory (the rest live in `@decocms/apps/vtex/hooks`). Future
|
|
1368
|
+
generic primitives that match the "module-level signal + queue +
|
|
1369
|
+
React hook" pattern can adopt the same `_internal`-with-non-React-
|
|
1370
|
+
setter shape — useful for SSR pre-fetch and tests.
|
|
1371
|
+
|
|
1289
1372
|
### Wave 15+ (htmx cleanup PRs on als + propagation to other sites) — Priority 3 / 4
|
|
1290
1373
|
|
|
1291
1374
|
Each htmx pattern that survives the codemod becomes a per-pattern PR
|
|
@@ -1445,3 +1528,64 @@ What's still ahead:
|
|
|
1445
1528
|
- **C8 (state persistence between migration phases)**: moderate effort, value mostly in skipping `npm install` on phase-9 retries. Polish.
|
|
1446
1529
|
- **`vibe-dex/*` orphan branches in apps-start**: ✅ all 5 cleaned this wave.
|
|
1447
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.27.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,10 @@
|
|
|
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",
|
|
25
|
+
"./sdk/useSuggestions": "./src/sdk/useSuggestions.ts",
|
|
22
26
|
"./sdk/retry": "./src/sdk/retry.ts",
|
|
23
27
|
"./sdk/useId": "./src/sdk/useId.ts",
|
|
24
28
|
"./sdk/cookie": "./src/sdk/cookie.ts",
|
|
@@ -95,7 +99,9 @@
|
|
|
95
99
|
},
|
|
96
100
|
"dependencies": {
|
|
97
101
|
"@deco-cx/warp-node": "^0.3.16",
|
|
102
|
+
"clsx": "^2.1.1",
|
|
98
103
|
"fast-json-patch": "^3.1.0",
|
|
104
|
+
"tailwind-merge": "^3.3.1",
|
|
99
105
|
"tsx": "^4.19.0",
|
|
100
106
|
"ws": "^8.18.0"
|
|
101
107
|
},
|
|
@@ -1078,6 +1078,32 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
|
|
|
1078
1078
|
description:
|
|
1079
1079
|
"src/sdk/url.ts overlaps with @decocms/apps/commerce/sdk/url → relative() (extended in @decocms/apps@1.9+)",
|
|
1080
1080
|
},
|
|
1081
|
+
{
|
|
1082
|
+
id: "use-suggestions",
|
|
1083
|
+
sitePath: "src/sdk/useSuggestions.ts",
|
|
1084
|
+
canonicalImport: "@decocms/start/sdk/useSuggestions",
|
|
1085
|
+
// Fingerprint: hand-rolled hook with the module-level signal +
|
|
1086
|
+
// serial-queue + latestQuery cancel pattern. Both casaevideo and
|
|
1087
|
+
// baggagio independently invented this exact shape. Sites that
|
|
1088
|
+
// already adopted `createUseSuggestions(…)` factory calls won't
|
|
1089
|
+
// match this signature.
|
|
1090
|
+
contentSignature: [
|
|
1091
|
+
/export\s+const\s+useSuggestions\s*=/,
|
|
1092
|
+
/\/deco\/invoke\//,
|
|
1093
|
+
/latestQuery/,
|
|
1094
|
+
],
|
|
1095
|
+
safeToAutoFix: false,
|
|
1096
|
+
reason:
|
|
1097
|
+
"rewrite to a 5-line factory shim: " +
|
|
1098
|
+
"`export const { useSuggestions } = createUseSuggestions<MySuggestion>({ onError });` " +
|
|
1099
|
+
"where MySuggestion is the site's payload type. The call sites " +
|
|
1100
|
+
"(`const { setQuery, payload, loading } = useSuggestions(loader)`) are unchanged. " +
|
|
1101
|
+
"Then delete src/sdk/useSuggestions.ts. Auto-fix is gated because " +
|
|
1102
|
+
"the per-site type parameter and onError wiring need site-specific " +
|
|
1103
|
+
"decisions. See references/platform-hooks-factories.md § useSuggestions.",
|
|
1104
|
+
description:
|
|
1105
|
+
"src/sdk/useSuggestions.ts duplicates @decocms/start/sdk/useSuggestions → createUseSuggestions() (added in @decocms/start@2.25+)",
|
|
1106
|
+
},
|
|
1081
1107
|
];
|
|
1082
1108
|
|
|
1083
1109
|
const ruleLocalFrameworkDuplicate: Rule = {
|
|
@@ -1267,6 +1267,50 @@ describe("rule: local-framework-duplicate", () => {
|
|
|
1267
1267
|
expect(r.findings[0].fix).toContain("stripSearchParams");
|
|
1268
1268
|
});
|
|
1269
1269
|
|
|
1270
|
+
it("flags src/sdk/useSuggestions.ts as warn-only (factory rewrite needed)", () => {
|
|
1271
|
+
const fs = makeFs({
|
|
1272
|
+
"/site/src/sdk/useSuggestions.ts":
|
|
1273
|
+
'import { signal } from "~/sdk/signal";\n' +
|
|
1274
|
+
"let queue = Promise.resolve();\n" +
|
|
1275
|
+
'let latestQuery = "";\n' +
|
|
1276
|
+
"export const useSuggestions = (loader) => {\n" +
|
|
1277
|
+
" const setQuery = (query) => {\n" +
|
|
1278
|
+
" latestQuery = query;\n" +
|
|
1279
|
+
' queue = queue.then(() => fetch(`/deco/invoke/${loader.__resolveType}`));\n' +
|
|
1280
|
+
" };\n" +
|
|
1281
|
+
" return { setQuery };\n" +
|
|
1282
|
+
"};\n",
|
|
1283
|
+
});
|
|
1284
|
+
const report = runAudit(SITE, fs);
|
|
1285
|
+
const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
|
|
1286
|
+
expect(r.findings).toHaveLength(1);
|
|
1287
|
+
expect(r.findings[0].file).toBe("src/sdk/useSuggestions.ts");
|
|
1288
|
+
expect(r.findings[0].meta?.id).toBe("use-suggestions");
|
|
1289
|
+
expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
|
|
1290
|
+
expect(r.findings[0].meta?.canonicalImport).toBe(
|
|
1291
|
+
"@decocms/start/sdk/useSuggestions",
|
|
1292
|
+
);
|
|
1293
|
+
expect(r.findings[0].fix).toContain("createUseSuggestions");
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
it("does NOT flag a useSuggestions.ts that already adopted the factory shim", () => {
|
|
1297
|
+
// Sites that completed the W15-B-2 migration end up with a
|
|
1298
|
+
// 5-line factory shim — no `latestQuery` or `/deco/invoke/`
|
|
1299
|
+
// strings. The rule's signature must NOT fire on the shim.
|
|
1300
|
+
const fs = makeFs({
|
|
1301
|
+
"/site/src/sdk/useSuggestions.ts":
|
|
1302
|
+
'import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";\n' +
|
|
1303
|
+
'import * as Sentry from "@sentry/react";\n' +
|
|
1304
|
+
"export const { useSuggestions } = createUseSuggestions({\n" +
|
|
1305
|
+
" onError: (err) => Sentry.captureException(err),\n" +
|
|
1306
|
+
"});\n",
|
|
1307
|
+
});
|
|
1308
|
+
const report = runAudit(SITE, fs);
|
|
1309
|
+
const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
|
|
1310
|
+
const finding = r.findings.find((f) => f.meta?.id === "use-suggestions");
|
|
1311
|
+
expect(finding).toBeUndefined();
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1270
1314
|
it("does NOT flag a forked url.ts that no longer carries the removeIdSku flag", () => {
|
|
1271
1315
|
// A site that already adopted an options-object-shaped local helper
|
|
1272
1316
|
// should not be flagged — the rule's signature is anchored on the
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clx, cn } from "./cn";
|
|
3
|
+
|
|
4
|
+
describe("cn", () => {
|
|
5
|
+
it("joins strings", () => {
|
|
6
|
+
expect(cn("a", "b", "c")).toBe("a b c");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("supports conditional objects", () => {
|
|
10
|
+
expect(cn("a", { b: true, c: false }, "d")).toBe("a b d");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("filters out falsy values", () => {
|
|
14
|
+
expect(cn("a", null, undefined, false, 0 as any, "b")).toBe("a b");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("merges conflicting Tailwind utilities (last one wins)", () => {
|
|
18
|
+
expect(cn("p-2", "p-4")).toBe("p-4");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("merges hover variants independently from base utilities", () => {
|
|
22
|
+
expect(cn("p-2", "hover:p-4")).toBe("p-2 hover:p-4");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("clx (re-exported)", () => {
|
|
27
|
+
it("filters falsy and joins with single spaces", () => {
|
|
28
|
+
expect(clx("a", null, "b", undefined, "c")).toBe("a b c");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does NOT merge conflicting utilities (that's cn's job)", () => {
|
|
32
|
+
expect(clx("p-2", "p-4")).toBe("p-2 p-4");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/sdk/cn.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cn(...inputs)` — the canonical Tailwind class-name combinator
|
|
3
|
+
* (clsx + tailwind-merge). Every storefront we audited shipped a
|
|
4
|
+
* site-local copy of this; we promote it to the framework so sites
|
|
5
|
+
* can drop the duplicate.
|
|
6
|
+
*
|
|
7
|
+
* Behaviour:
|
|
8
|
+
* - Accepts the full `clsx` input format (strings, objects, arrays,
|
|
9
|
+
* conditionals, falsy values).
|
|
10
|
+
* - De-duplicates conflicting Tailwind utilities via `tailwind-merge`
|
|
11
|
+
* (e.g. `cn("p-2", "p-4")` → `"p-4"`).
|
|
12
|
+
*
|
|
13
|
+
* The simpler `clx` (no tailwind-merge, just `filter+join`) is still
|
|
14
|
+
* exported from `@decocms/start/sdk/clx` for cases where you want to
|
|
15
|
+
* keep the literal class string. Re-exported here so a single import
|
|
16
|
+
* covers both.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { clsx, type ClassValue } from "clsx";
|
|
20
|
+
import { twMerge } from "tailwind-merge";
|
|
21
|
+
|
|
22
|
+
export { clx } from "./clx";
|
|
23
|
+
|
|
24
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
25
|
+
return twMerge(clsx(inputs));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type { ClassValue };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
deleteResponseCookie,
|
|
4
|
+
getCookies,
|
|
5
|
+
setResponseCookie,
|
|
6
|
+
} from "./cookie";
|
|
7
|
+
|
|
8
|
+
describe("getCookies", () => {
|
|
9
|
+
it("returns an empty object when no Cookie header is present", () => {
|
|
10
|
+
expect(getCookies(new Headers())).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("parses a single cookie", () => {
|
|
14
|
+
const h = new Headers({ cookie: "session=abc" });
|
|
15
|
+
expect(getCookies(h)).toEqual({ session: "abc" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses multiple cookies separated by '; '", () => {
|
|
19
|
+
const h = new Headers({ cookie: "a=1; b=2; c=3" });
|
|
20
|
+
expect(getCookies(h)).toEqual({ a: "1", b: "2", c: "3" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("URL-decodes values", () => {
|
|
24
|
+
const h = new Headers({ cookie: "u=hello%20world" });
|
|
25
|
+
expect(getCookies(h)).toEqual({ u: "hello world" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back to the raw value when decoding fails", () => {
|
|
29
|
+
// Lone '%' is invalid in URL encoding.
|
|
30
|
+
const h = new Headers({ cookie: "x=100%" });
|
|
31
|
+
expect(getCookies(h)).toEqual({ x: "100%" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("ignores entries without an '='", () => {
|
|
35
|
+
const h = new Headers({ cookie: "garbage; ok=yes" });
|
|
36
|
+
expect(getCookies(h)).toEqual({ ok: "yes" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("trims whitespace around names", () => {
|
|
40
|
+
const h = new Headers({ cookie: " a=1; b=2 " });
|
|
41
|
+
expect(getCookies(h)).toEqual({ a: "1", b: "2" });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("setResponseCookie", () => {
|
|
46
|
+
it("appends a Set-Cookie header with the cookie name and value", () => {
|
|
47
|
+
const h = new Headers();
|
|
48
|
+
setResponseCookie(h, { name: "session", value: "abc" });
|
|
49
|
+
expect(h.get("set-cookie")).toBe("session=abc");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("serializes maxAge, path, secure, httpOnly, sameSite, domain", () => {
|
|
53
|
+
const h = new Headers();
|
|
54
|
+
setResponseCookie(h, {
|
|
55
|
+
name: "session",
|
|
56
|
+
value: "abc",
|
|
57
|
+
maxAge: 3600,
|
|
58
|
+
path: "/",
|
|
59
|
+
domain: "example.com",
|
|
60
|
+
secure: true,
|
|
61
|
+
httpOnly: true,
|
|
62
|
+
sameSite: "Lax",
|
|
63
|
+
});
|
|
64
|
+
const value = h.get("set-cookie")!;
|
|
65
|
+
expect(value).toContain("session=abc");
|
|
66
|
+
expect(value).toContain("Max-Age=3600");
|
|
67
|
+
expect(value).toContain("Domain=example.com");
|
|
68
|
+
expect(value).toContain("Path=/");
|
|
69
|
+
expect(value).toContain("Secure");
|
|
70
|
+
expect(value).toContain("HttpOnly");
|
|
71
|
+
expect(value).toContain("SameSite=Lax");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("serializes expires as a UTC string", () => {
|
|
75
|
+
const h = new Headers();
|
|
76
|
+
const date = new Date("2030-01-01T00:00:00Z");
|
|
77
|
+
setResponseCookie(h, { name: "x", value: "y", expires: date });
|
|
78
|
+
expect(h.get("set-cookie")).toContain(`Expires=${date.toUTCString()}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("appends multiple cookies (does not overwrite the first)", () => {
|
|
82
|
+
const h = new Headers();
|
|
83
|
+
setResponseCookie(h, { name: "a", value: "1" });
|
|
84
|
+
setResponseCookie(h, { name: "b", value: "2" });
|
|
85
|
+
// Headers.getAll isn't standard; getSetCookie() is the modern API.
|
|
86
|
+
const all = (h as any).getSetCookie?.() as string[] | undefined;
|
|
87
|
+
if (all) {
|
|
88
|
+
expect(all).toEqual(["a=1", "b=2"]);
|
|
89
|
+
} else {
|
|
90
|
+
// Fallback: the combined header value should mention both.
|
|
91
|
+
const v = h.get("set-cookie")!;
|
|
92
|
+
expect(v).toContain("a=1");
|
|
93
|
+
expect(v).toContain("b=2");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("deleteResponseCookie", () => {
|
|
99
|
+
it("emits a Set-Cookie that expires immediately", () => {
|
|
100
|
+
const h = new Headers();
|
|
101
|
+
deleteResponseCookie(h, "session", { path: "/" });
|
|
102
|
+
const value = h.get("set-cookie")!;
|
|
103
|
+
expect(value).toContain("session=");
|
|
104
|
+
expect(value).toContain("Max-Age=0");
|
|
105
|
+
expect(value).toContain(`Expires=${new Date(0).toUTCString()}`);
|
|
106
|
+
expect(value).toContain("Path=/");
|
|
107
|
+
});
|
|
108
|
+
});
|