@decocms/start 2.24.0 → 2.26.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.
@@ -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 above.
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).
@@ -430,6 +430,8 @@ logic, wrapped in something else) are skipped automatically.
430
430
  | `src/sdk/clx.ts` | `@decocms/start/sdk/clx` | yes | Identical implementation; baggagio's extra `clsx` alias has zero callers. |
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
+ | `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. |
433
435
 
434
436
  ### Adding a new entry
435
437
 
@@ -1205,19 +1205,169 @@ 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
1216
1211
  picking-the-winner pass between casaevideo's and baggagio's
1217
1212
  shapes, plus a codemod for call sites).
1218
- - **15-B-5** — `relative()` SKU-stripping option in
1219
- `@decocms/apps/commerce/sdk/url`. Apps-side change; sites delete
1220
- their wrapper.
1213
+
1214
+ ### Wave 15-B-5 (canonical `relative()` + audit registry entry — apps + deco-start) 🟡 **IN FLIGHT**
1215
+
1216
+ The smallest 15-B slice: extend `commerce/sdk/url.ts → relative()`
1217
+ with a generic options bag, then point the audit at the canonical
1218
+ so future site forks get caught automatically.
1219
+
1220
+ Verified state (2026-05-01 grep against baggagio-tanstack):
1221
+
1222
+ - `src/sdk/url.ts` carries a positional 2-arg fork (`relative(link,
1223
+ removeIdSku?: boolean)`) with VTEX-specific keys (`idsku`,
1224
+ `skuId`) hardcoded inside.
1225
+ - 9 importers in baggagio. ONE of them — `ProductCard.tsx` — uses
1226
+ the 2-arg form (via prop `removeIdSkuFromUrl`). The other 8 use
1227
+ the 1-arg form, identical to the apps canonical.
1228
+ - casaevideo doesn't carry a fork.
1229
+
1230
+ So the convergence is one apps-side extension + one audit registry
1231
+ entry. The single `ProductCard` call site rewrites by hand or by a
1232
+ future codemod (out of scope here).
1233
+
1234
+ **Shipped (two PRs):**
1235
+
1236
+ 39. [`apps-start#36`](https://github.com/decocms/apps-start/pull/36) — `feat(commerce/sdk): extend relative() with stripSearchParams option` ✅ **MERGED** (will release as `@decocms/apps@1.9.x`).
1237
+ - **`commerce/sdk/url.ts`**: backwards-compatible second
1238
+ `RelativeOptions` argument with `stripSearchParams?:
1239
+ string[]` primitive. 1-arg callers (everyone in apps + 8/9
1240
+ of baggagio's call sites) unaffected. The byte-for-byte
1241
+ "://path-style" passthrough is locked in by an explicit
1242
+ backwards-compat test.
1243
+ - **Why generic, not `removeIdSku?: boolean`**: hardcoded VTEX
1244
+ key names belong at call sites, not in a generic commerce
1245
+ helper. `stripSearchParams: string[]` works for any platform.
1246
+ Sites pass `["idsku", "skuId"]` themselves — honest about
1247
+ where the platform knowledge lives.
1248
+ - **`commerce/__tests__/url.test.ts`** (new): 18 tests covering
1249
+ base behaviour (relative/absolute/undefined/empty/malformed
1250
+ via `toString()` thrower), `stripSearchParams` primitive
1251
+ (single, multi, empty, missing, repeated keys, all-stripped
1252
+ → drop trailing `?`), and three explicit backwards-compat
1253
+ assertions.
1254
+ - 290/290 tests pass, typecheck + biome clean.
1255
+
1256
+ 40. `feat(migrate): add url-relative entry to local-framework-duplicate registry` 🟡 **WAITING ON CI** (deco-start side).
1257
+ - **Registry entry** in `FRAMEWORK_DUPLICATES` for
1258
+ `src/sdk/url.ts` → `@decocms/apps/commerce/sdk/url`. Content
1259
+ signature anchored on the legacy positional `removeIdSku?:
1260
+ boolean` shape so sites that already adopted the canonical
1261
+ options-object aren't flagged.
1262
+ - **`safeToAutoFix: false`** — the call-site rewrite from
1263
+ positional `relative(url, true)` to `relative(url, {
1264
+ stripSearchParams: ["idsku", "skuId"] })` requires JSX/TS-
1265
+ aware transformation, not pure import rewrite. The finding's
1266
+ `fix:` field carries the exact recipe.
1267
+ - **Two new tests**: positive case (legacy fork → flagged with
1268
+ the correct hint), negative case (canonical-shaped local
1269
+ fork that already adopted the options object → NOT flagged,
1270
+ proves the signature-anchoring works).
1271
+ - **Skill doc § 8 table** updated with the 4th entry, including
1272
+ version pin (`@decocms/apps@1.9+`).
1273
+ - 355/355 tests pass, typecheck clean. Smoke against baggagio
1274
+ now fires 3 findings (was 2): clx, useSendEvent, url; smoke
1275
+ against casaevideo unchanged at 1 (location-matcher).
1276
+
1277
+ **Process note**: this is the first time we ran the apps-side and
1278
+ deco-start-side as a pair of PRs. The order matters — apps-start
1279
+ must merge first so the deco-start audit registry can point at
1280
+ the released canonical. The skill doc explicitly version-pins the
1281
+ canonical (`@decocms/apps@1.9+`) so engineers reading the audit
1282
+ output know whether they need to bump apps before adopting.
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.
1221
1371
 
1222
1372
  ### Wave 15+ (htmx cleanup PRs on als + propagation to other sites) — Priority 3 / 4
1223
1373
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.24.0",
3
+ "version": "2.26.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,7 @@
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/useSuggestions": "./src/sdk/useSuggestions.ts",
22
23
  "./sdk/retry": "./src/sdk/retry.ts",
23
24
  "./sdk/useId": "./src/sdk/useId.ts",
24
25
  "./sdk/cookie": "./src/sdk/cookie.ts",
@@ -1053,6 +1053,57 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
1053
1053
  description:
1054
1054
  "src/matchers/location.ts overlaps with @decocms/start/matchers/builtins → registerBuiltinMatchers()",
1055
1055
  },
1056
+ {
1057
+ id: "url-relative",
1058
+ sitePath: "src/sdk/url.ts",
1059
+ canonicalImport: "@decocms/apps/commerce/sdk/url",
1060
+ // Fingerprint: site fork carries a positional `removeIdSku?: boolean`
1061
+ // flag + hardcoded VTEX-specific keys (`idsku`, `skuId`). Canonical
1062
+ // apps export uses an options object — `{ stripSearchParams: string[] }`
1063
+ // — which is generic and platform-agnostic.
1064
+ contentSignature: [
1065
+ /export\s+const\s+relative\s*=/,
1066
+ /removeIdSku\s*\?\s*:\s*boolean/,
1067
+ /['"](idsku|skuId)['"]/,
1068
+ ],
1069
+ safeToAutoFix: false,
1070
+ reason:
1071
+ "rewrite imports to '@decocms/apps/commerce/sdk/url'. " +
1072
+ "Each call site using the boolean form `relative(url, true)` becomes " +
1073
+ "`relative(url, { stripSearchParams: [\"idsku\", \"skuId\"] })`. " +
1074
+ "1-arg calls are unchanged. Then delete src/sdk/url.ts. " +
1075
+ "Auto-fix is gated because the call-site rewrite needs JSX/TS-aware " +
1076
+ "transformation (positional bool → options object), not pure import " +
1077
+ "rewrite.",
1078
+ description:
1079
+ "src/sdk/url.ts overlaps with @decocms/apps/commerce/sdk/url → relative() (extended in @decocms/apps@1.9+)",
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
+ },
1056
1107
  ];
1057
1108
 
1058
1109
  const ruleLocalFrameworkDuplicate: Rule = {
@@ -1240,6 +1240,95 @@ describe("rule: local-framework-duplicate", () => {
1240
1240
  expect(r.findings[0].fix).toContain("typed AnalyticsEvent generic");
1241
1241
  });
1242
1242
 
1243
+ it("flags src/sdk/url.ts as warn-only (positional bool → options object call-site rewrite)", () => {
1244
+ const fs = makeFs({
1245
+ "/site/src/sdk/url.ts":
1246
+ "export const relative = (\n" +
1247
+ " link?: string | undefined,\n" +
1248
+ " removeIdSku?: boolean,\n" +
1249
+ ") => {\n" +
1250
+ ' const linkUrl = link ? new URL(link, "http://localhost") : undefined;\n' +
1251
+ " if (linkUrl && removeIdSku) {\n" +
1252
+ ' linkUrl.searchParams.delete("idsku");\n' +
1253
+ ' linkUrl.searchParams.delete("skuId");\n' +
1254
+ " }\n" +
1255
+ " return linkUrl ? `${linkUrl.pathname}` : undefined;\n" +
1256
+ "};\n",
1257
+ });
1258
+ const report = runAudit(SITE, fs);
1259
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1260
+ expect(r.findings).toHaveLength(1);
1261
+ expect(r.findings[0].file).toBe("src/sdk/url.ts");
1262
+ expect(r.findings[0].meta?.id).toBe("url-relative");
1263
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1264
+ expect(r.findings[0].meta?.canonicalImport).toBe(
1265
+ "@decocms/apps/commerce/sdk/url",
1266
+ );
1267
+ expect(r.findings[0].fix).toContain("stripSearchParams");
1268
+ });
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
+
1314
+ it("does NOT flag a forked url.ts that no longer carries the removeIdSku flag", () => {
1315
+ // A site that already adopted an options-object-shaped local helper
1316
+ // should not be flagged — the rule's signature is anchored on the
1317
+ // legacy positional-boolean shape.
1318
+ const fs = makeFs({
1319
+ "/site/src/sdk/url.ts":
1320
+ "export const relative = (link?: string, options?: { stripSearchParams?: string[] }) => {\n" +
1321
+ ' if (!link) return undefined;\n' +
1322
+ ' return new URL(link, "https://localhost").pathname;\n' +
1323
+ "};\n",
1324
+ });
1325
+ const report = runAudit(SITE, fs);
1326
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1327
+ // url-relative entry must NOT fire on the canonical-shaped local fork.
1328
+ const urlFinding = r.findings.find((f) => f.meta?.id === "url-relative");
1329
+ expect(urlFinding).toBeUndefined();
1330
+ });
1331
+
1243
1332
  it("flags src/matchers/location.ts as warn-only (behaviour-superset opportunity)", () => {
1244
1333
  const fs = makeFs({
1245
1334
  "/site/src/matchers/location.ts":
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Tests for the `createUseSuggestions` factory.
3
+ *
4
+ * The hook itself depends on React (useCallback). This file exercises
5
+ * the parts of the factory that don't need a React renderer:
6
+ * - factory shape + isolation between calls
7
+ * - the non-React `_internal.setQuery` flow which carries every bit
8
+ * of behaviour the React hook delegates to (queue, cancel guard,
9
+ * loading-flag invariants, error path)
10
+ *
11
+ * Hook-level integration is exercised by the site-level smoke (the
12
+ * factory has shipped to two production sites with the same shape).
13
+ */
14
+
15
+ import { describe, expect, it, vi } from "vitest";
16
+ import { createUseSuggestions } from "./useSuggestions";
17
+
18
+ interface FakeSuggestion {
19
+ products: string[];
20
+ }
21
+
22
+ const FAKE_LOADER = {
23
+ __resolveType: "site/loaders/search/suggestions.ts",
24
+ limit: 5,
25
+ } as unknown as FakeSuggestion;
26
+
27
+ function makeOkFetch(payload: unknown, delayMs = 0): typeof fetch {
28
+ return ((_input: RequestInfo | URL, _init?: RequestInit) =>
29
+ new Promise((resolve) => {
30
+ setTimeout(
31
+ () =>
32
+ resolve(
33
+ new Response(JSON.stringify(payload), {
34
+ status: 200,
35
+ headers: { "Content-Type": "application/json" },
36
+ }),
37
+ ),
38
+ delayMs,
39
+ );
40
+ })) as typeof fetch;
41
+ }
42
+
43
+ describe("createUseSuggestions — factory shape", () => {
44
+ it("returns useSuggestions + _internal", () => {
45
+ const f = createUseSuggestions<FakeSuggestion>();
46
+ expect(typeof f.useSuggestions).toBe("function");
47
+ expect(typeof f._internal.setQuery).toBe("function");
48
+ expect(typeof f._internal.drain).toBe("function");
49
+ expect(f._internal.payload.value).toBeNull();
50
+ expect(f._internal.loading.value).toBe(false);
51
+ });
52
+
53
+ it("two factory calls produce independent state + functions", () => {
54
+ const a = createUseSuggestions<FakeSuggestion>();
55
+ const b = createUseSuggestions<FakeSuggestion>();
56
+ expect(a.useSuggestions).not.toBe(b.useSuggestions);
57
+ expect(a._internal.payload).not.toBe(b._internal.payload);
58
+ expect(a._internal.loading).not.toBe(b._internal.loading);
59
+ });
60
+ });
61
+
62
+ describe("createUseSuggestions — fetch happy path", () => {
63
+ it("posts to /deco/invoke/<__resolveType> with the query + extra props", async () => {
64
+ const spy = vi.fn(makeOkFetch({ products: ["a", "b"] }));
65
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl: spy });
66
+ f._internal.setQuery("samsung", FAKE_LOADER);
67
+ await f._internal.drain();
68
+
69
+ expect(spy).toHaveBeenCalledTimes(1);
70
+ const [url, init] = spy.mock.calls[0];
71
+ expect(url).toBe("/deco/invoke/site/loaders/search/suggestions.ts");
72
+ expect(init?.method).toBe("POST");
73
+ expect(JSON.parse(init?.body as string)).toEqual({
74
+ query: "samsung",
75
+ limit: 5,
76
+ });
77
+ });
78
+
79
+ it("populates payload with the parsed response", async () => {
80
+ const f = createUseSuggestions<FakeSuggestion>({
81
+ fetchImpl: makeOkFetch({ products: ["a", "b"] }),
82
+ });
83
+ f._internal.setQuery("samsung", FAKE_LOADER);
84
+ await f._internal.drain();
85
+ expect(f._internal.payload.value).toEqual({ products: ["a", "b"] });
86
+ });
87
+
88
+ it("flips loading to true synchronously, back to false after fetch settles", async () => {
89
+ const f = createUseSuggestions<FakeSuggestion>({
90
+ fetchImpl: makeOkFetch({ products: [] }),
91
+ });
92
+ expect(f._internal.loading.value).toBe(false);
93
+ f._internal.setQuery("hi", FAKE_LOADER);
94
+ expect(f._internal.loading.value).toBe(true);
95
+ await f._internal.drain();
96
+ expect(f._internal.loading.value).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("createUseSuggestions — cancel + queue semantics", () => {
101
+ it("cancels older queries BEFORE they fetch — only the latest hits the network", async () => {
102
+ // Mock echoes the body's `query` field so we can tell which
103
+ // invocation actually reached the network.
104
+ const calls: string[] = [];
105
+ const fetchImpl: typeof fetch = ((_url, init) => {
106
+ const body = JSON.parse(init?.body as string) as { query: string };
107
+ calls.push(body.query);
108
+ return Promise.resolve(
109
+ new Response(JSON.stringify({ products: [body.query] }), {
110
+ status: 200,
111
+ }),
112
+ );
113
+ }) as typeof fetch;
114
+
115
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
116
+ // Three queries kicked off back-to-back synchronously.
117
+ f._internal.setQuery("a", FAKE_LOADER);
118
+ f._internal.setQuery("b", FAKE_LOADER);
119
+ f._internal.setQuery("c", FAKE_LOADER);
120
+ await f._internal.drain();
121
+
122
+ // Only the latest query reaches the network — the cancel
123
+ // guard short-circuits the first two before they fetch.
124
+ expect(calls).toEqual(["c"]);
125
+ expect(f._internal.payload.value).toEqual({ products: ["c"] });
126
+ });
127
+
128
+ it("the latest-query guard prevents stale fetches from clearing loading prematurely", async () => {
129
+ // If the cancel guard ever regresses, this is the test that
130
+ // catches it: we kick off fetch #1, immediately call setQuery
131
+ // again, await drain, and expect the FINAL state to reflect
132
+ // the latest query — not an inconsistent "loading false but
133
+ // payload stale" mid-state.
134
+ const fetchImpl = makeOkFetch({ products: ["latest"] }, 5);
135
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
136
+ f._internal.setQuery("a", FAKE_LOADER);
137
+ f._internal.setQuery("b", FAKE_LOADER);
138
+ f._internal.setQuery("c", FAKE_LOADER);
139
+ await f._internal.drain();
140
+ expect(f._internal.loading.value).toBe(false);
141
+ expect(f._internal.payload.value).toEqual({ products: ["latest"] });
142
+ });
143
+
144
+ it("queues serially — fetches don't run concurrently", async () => {
145
+ // Race detector: track whether the count of in-flight fetches
146
+ // ever exceeds 1.
147
+ let inflight = 0;
148
+ let maxInflight = 0;
149
+ const fetchImpl: typeof fetch = (() =>
150
+ new Promise<Response>((resolve) => {
151
+ inflight += 1;
152
+ maxInflight = Math.max(maxInflight, inflight);
153
+ setTimeout(() => {
154
+ inflight -= 1;
155
+ resolve(
156
+ new Response(JSON.stringify({ products: [] }), { status: 200 }),
157
+ );
158
+ }, 5);
159
+ })) as typeof fetch;
160
+
161
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
162
+ f._internal.setQuery("a", FAKE_LOADER);
163
+ f._internal.setQuery("b", FAKE_LOADER);
164
+ f._internal.setQuery("c", FAKE_LOADER);
165
+ await f._internal.drain();
166
+ expect(maxInflight).toBe(1);
167
+ });
168
+ });
169
+
170
+ describe("createUseSuggestions — error path", () => {
171
+ it("forwards thrown errors to onError + console.error, does NOT update payload", async () => {
172
+ const onError = vi.fn();
173
+ const consoleError = vi
174
+ .spyOn(console, "error")
175
+ .mockImplementation(() => {});
176
+
177
+ const fetchImpl = (() =>
178
+ Promise.reject(new Error("network down"))) as typeof fetch;
179
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl, onError });
180
+
181
+ f._internal.setQuery("samsung", FAKE_LOADER);
182
+ await f._internal.drain();
183
+
184
+ expect(onError).toHaveBeenCalledTimes(1);
185
+ const [err, query] = onError.mock.calls[0];
186
+ expect((err as Error).message).toBe("network down");
187
+ expect(query).toBe("samsung");
188
+ expect(consoleError).toHaveBeenCalled();
189
+ expect(f._internal.payload.value).toBeNull();
190
+ expect(f._internal.loading.value).toBe(false);
191
+
192
+ consoleError.mockRestore();
193
+ });
194
+
195
+ it("non-2xx responses surface as errors", async () => {
196
+ const onError = vi.fn();
197
+ const consoleError = vi
198
+ .spyOn(console, "error")
199
+ .mockImplementation(() => {});
200
+ const fetchImpl = (() =>
201
+ Promise.resolve(
202
+ new Response("internal error", { status: 500 }),
203
+ )) as typeof fetch;
204
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl, onError });
205
+
206
+ f._internal.setQuery("x", FAKE_LOADER);
207
+ await f._internal.drain();
208
+
209
+ expect(onError).toHaveBeenCalledTimes(1);
210
+ expect((onError.mock.calls[0][0] as Error).message).toContain("500");
211
+ expect(f._internal.payload.value).toBeNull();
212
+ expect(f._internal.loading.value).toBe(false);
213
+
214
+ consoleError.mockRestore();
215
+ });
216
+
217
+ it("does NOT throw if onError is omitted (still console.errors)", async () => {
218
+ const consoleError = vi
219
+ .spyOn(console, "error")
220
+ .mockImplementation(() => {});
221
+ const fetchImpl = (() =>
222
+ Promise.reject(new Error("boom"))) as typeof fetch;
223
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
224
+
225
+ f._internal.setQuery("x", FAKE_LOADER);
226
+ await expect(f._internal.drain()).resolves.toBeUndefined();
227
+ expect(consoleError).toHaveBeenCalled();
228
+ consoleError.mockRestore();
229
+ });
230
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Search-suggestions hook factory.
3
+ *
4
+ * Both casaevideo-storefront and baggagio-tanstack independently
5
+ * invented the same shape for autocomplete-style suggestions:
6
+ *
7
+ * - module-level signal for the current payload + loading flag
8
+ * - serial in-flight queue so older requests can't race past newer ones
9
+ * - "is this still the latest query?" cancel guard
10
+ * - posts to `/deco/invoke/<__resolveType>` with the loader's
11
+ * extra props + the live `query` string
12
+ *
13
+ * This factory is the canonical version. Sites instantiate it once
14
+ * at module load with their concrete suggestion type and (optionally)
15
+ * an `onError` hook for observability (Sentry / OpenTelemetry / etc.).
16
+ *
17
+ * Why a factory and not a plain hook:
18
+ * - Each call to `createUseSuggestions()` produces an isolated
19
+ * `payload` / `loading` / queue. Keeps the door open for sites
20
+ * with multiple independent suggestion streams (e.g. searchbar
21
+ * *and* a category-jump suggester) without globally-shared state.
22
+ * - Type narrowing happens at the factory boundary, not at hook
23
+ * usage — the returned `useSuggestions` is already specialised
24
+ * on `T` so callers don't need to re-pass generics.
25
+ * - Mirrors the existing `createUseCart` / `createUseUser` /
26
+ * `createUseWishlist` factory pattern from
27
+ * `@decocms/apps/vtex/hooks/*`. See
28
+ * `references/platform-hooks-factories.md`.
29
+ */
30
+
31
+ import { useCallback } from "react";
32
+ import type { Resolved } from "../types";
33
+ import { signal, type ReactiveSignal } from "./signal";
34
+
35
+ /**
36
+ * Optional dependencies the factory accepts at instantiation time.
37
+ *
38
+ * Most are pure observability hooks — the factory itself runs a
39
+ * `console.error()` after invoking `onError`, so callers don't have
40
+ * to remember to forward the error to the console themselves.
41
+ */
42
+ export interface UseSuggestionsOptions {
43
+ /**
44
+ * Called once per failed fetch with the original error and the
45
+ * query that triggered it. Use for Sentry / OTEL captures.
46
+ *
47
+ * The factory still calls `console.error` after this returns so
48
+ * sites that don't wire `onError` keep the same console output.
49
+ */
50
+ onError?: (error: unknown, query: string) => void;
51
+
52
+ /**
53
+ * Override the fetch implementation. Tests pass a stub here; the
54
+ * default is the global `fetch`. Production sites have no reason
55
+ * to set this.
56
+ */
57
+ fetchImpl?: typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * Shape returned by the hook produced by `createUseSuggestions`.
62
+ *
63
+ * `loading` and `payload` are reactive signals — subscribe with
64
+ * `useStore()` from `@tanstack/react-store` (or read `.value`
65
+ * directly inside an `useEffect`).
66
+ */
67
+ export interface UseSuggestionsReturn<T> {
68
+ loading: ReactiveSignal<boolean>;
69
+ payload: ReactiveSignal<T | null>;
70
+ /**
71
+ * Trigger a suggestion fetch for `query`. Calls coalesce through
72
+ * a serial promise queue, and only the *latest* query's result
73
+ * is allowed to flip `loading` back to `false` — so rapid keystrokes
74
+ * don't leave the UI permanently in a stale loading state.
75
+ */
76
+ setQuery: (query: string) => void;
77
+ }
78
+
79
+ /**
80
+ * Returned by {@link createUseSuggestions}.
81
+ *
82
+ * `useSuggestions` is the React hook bound to this factory's state.
83
+ * The `_internal` field exposes the underlying signals and a non-
84
+ * React `setQuery` for advanced cases (SSR pre-population, unit
85
+ * tests, server-side warmup). Sites almost never need it.
86
+ */
87
+ export interface CreateUseSuggestionsReturn<T> {
88
+ useSuggestions: (loader: Resolved<T | null>) => UseSuggestionsReturn<T>;
89
+ _internal: {
90
+ readonly payload: ReactiveSignal<T | null>;
91
+ readonly loading: ReactiveSignal<boolean>;
92
+ /**
93
+ * Same semantics as `useSuggestions(...).setQuery`, but pure JS —
94
+ * no React hook context required. Useful in unit tests and for
95
+ * SSR pre-fetch helpers.
96
+ */
97
+ setQuery: (query: string, loader: Resolved<T | null>) => void;
98
+ /**
99
+ * Promise that resolves once every queued fetch has settled.
100
+ * Tests await this to assert post-cancellation state.
101
+ */
102
+ readonly drain: () => Promise<unknown>;
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Build a typed `useSuggestions` hook bound to a private
108
+ * `payload` / `loading` / queue tuple. Call once per stream at
109
+ * module load.
110
+ *
111
+ * @example
112
+ * // site/src/sdk/useSuggestions.ts
113
+ * import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";
114
+ * import * as Sentry from "@sentry/react";
115
+ * import type { Suggestion } from "@decocms/apps/commerce/types";
116
+ *
117
+ * export const { useSuggestions } = createUseSuggestions<Suggestion>({
118
+ * onError: (err) => Sentry.captureException(err),
119
+ * });
120
+ */
121
+ export function createUseSuggestions<T>(
122
+ options: UseSuggestionsOptions = {},
123
+ ): CreateUseSuggestionsReturn<T> {
124
+ const payload = signal<T | null>(null);
125
+ const loading = signal<boolean>(false);
126
+ let queue: Promise<unknown> = Promise.resolve();
127
+ let latestQuery = "";
128
+
129
+ const fetchImpl = options.fetchImpl ?? fetch;
130
+ const onError = options.onError;
131
+
132
+ async function doFetch(
133
+ query: string,
134
+ resolved: Resolved<T | null>,
135
+ ): Promise<void> {
136
+ if (latestQuery !== query) return;
137
+
138
+ const { __resolveType, ...extraProps } = resolved as {
139
+ __resolveType: string;
140
+ [k: string]: unknown;
141
+ };
142
+
143
+ try {
144
+ const response = await fetchImpl(`/deco/invoke/${__resolveType}`, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ query, ...extraProps }),
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`Suggestions invoke failed: ${response.status}`);
151
+ }
152
+ payload.value = (await response.json()) as T | null;
153
+ } catch (error) {
154
+ onError?.(error, query);
155
+ console.error("[useSuggestions] fetch failed:", error);
156
+ } finally {
157
+ // Only the latest query is allowed to flip the loading flag —
158
+ // otherwise rapid keystrokes can leave the UI in a stale
159
+ // "still loading" state because an older fetch resolved last.
160
+ if (latestQuery === query) loading.value = false;
161
+ }
162
+ }
163
+
164
+ function setQueryImpl(query: string, loader: Resolved<T | null>): void {
165
+ loading.value = true;
166
+ latestQuery = query;
167
+ queue = queue.then(() => doFetch(query, loader));
168
+ }
169
+
170
+ function useSuggestions(loader: Resolved<T | null>): UseSuggestionsReturn<T> {
171
+ const setQuery = useCallback(
172
+ (query: string) => setQueryImpl(query, loader),
173
+ [loader],
174
+ );
175
+
176
+ return { loading, payload, setQuery };
177
+ }
178
+
179
+ return {
180
+ useSuggestions,
181
+ _internal: {
182
+ payload,
183
+ loading,
184
+ setQuery: setQueryImpl,
185
+ drain: () => queue,
186
+ },
187
+ };
188
+ }