@decocms/start 2.19.0 → 2.21.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.
@@ -369,6 +369,7 @@ For sites with 100+ sections:
369
369
  | Admin / CMS integration | `references/admin-cms.md` |
370
370
  | Gotchas index | `references/gotchas.md` |
371
371
  | Post-migration cleanup checklist | `references/post-migration-cleanup.md` |
372
+ | HTMX → React rewrite recipes | `references/htmx-rewrite.md` |
372
373
  | React hooks patterns | `references/react-hooks-patterns.md` |
373
374
  | React signals & state | `references/react-signals-state.md` |
374
375
  | JSX migration differences | `references/jsx-migration.md` |
@@ -0,0 +1,527 @@
1
+ # HTMX → React Rewrite Recipes
2
+
3
+ Some Deco storefronts on the Fresh stack picked up htmx as their
4
+ interactivity model — `hx-get`, `hx-post`, `hx-target`, `hx-swap`,
5
+ `hx-on:click`, etc. The TanStack Start / React stack does **not**
6
+ ship an htmx runtime, and per **D2** in the
7
+ [migration tooling policy](https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc)
8
+ we don't add one — every `hx-*` attribute gets rewritten to a React
9
+ equivalent on migration. There is no half-measure adapter package.
10
+
11
+ This reference is the per-pattern playbook the engineer (and the
12
+ Wave 14 codemods) follow.
13
+
14
+ ## Inventory the surface first
15
+
16
+ Before rewriting anything, run the analyzer (added in
17
+ `@decocms/start >= 2.20.0`):
18
+
19
+ ```bash
20
+ # From the SOURCE site directory (Fresh repo, before migration).
21
+ npx -p @decocms/start deco-htmx-analyze --top 20
22
+
23
+ # Or after migration to verify there's nothing left:
24
+ cd /path/to/migrated-site
25
+ npx -p @decocms/start deco-htmx-analyze --json | jq '.totalOccurrences'
26
+ ```
27
+
28
+ The analyzer groups every `hx-*` element into one of seven
29
+ **categories**, ordered roughly by rewrite difficulty:
30
+
31
+ | Category | Cluster | Difficulty |
32
+ |---|---|---|
33
+ | `event-handler` | `hx-on:*` / `hx-on-*`, no fetch attr | trivial — direct `onClick` |
34
+ | `boost` | `hx-boost="true"` | trivial — `<Link>` |
35
+ | `click-swap` | fetch + `hx-target` on a button-like element | medium — state + conditional render |
36
+ | `form-swap` | fetch on `<form>` with target/swap | medium — `useMutation` |
37
+ | `auto-fetch` | fetch on input or `hx-trigger=keyup\|intersect` | medium — debounced state + `useQuery` |
38
+ | `oob-swap` | `hx-swap-oob` / `hx-select-oob` | hard — manual, no 1:1 |
39
+ | `unmatched` | doesn't fit a known cluster | manual — read the call site |
40
+
41
+ **Order of attack**: walk the list top-down. Most sites' surface is
42
+ ~40 % event-handler, ~30 % click-swap, ~10 % each form-swap and
43
+ auto-fetch, ~5 % each oob-swap and unmatched. Knock out the trivial
44
+ ones first — they're often the easiest wins and clear the noise.
45
+
46
+ ## Two syntactic flavours of `hx-on`
47
+
48
+ htmx 2.x supports both colon and dash forms of event handler attrs.
49
+ HTML's spec doesn't allow `:` in attribute names, so the dash is
50
+ canonical:
51
+
52
+ ```tsx
53
+ <button hx-on:click={useScript(...)} /> // colon (older Fresh fixtures)
54
+ <button hx-on-click={useScript(...)} /> // dash (htmx 2.x canonical)
55
+ <div hx-on-htmx-after-request={...} /> // htmx event with prefix
56
+ ```
57
+
58
+ Both rewrite to the same React `onClick` / `onChange` etc.
59
+
60
+ ## Pattern 1 — `event-handler`
61
+
62
+ The biggest bucket. Almost always a `useScript`-wrapped function
63
+ attached as an event handler, with no fetch involved.
64
+
65
+ ### Before
66
+
67
+ ```tsx
68
+ <button
69
+ hx-on:click={useScript(
70
+ async (skuId: string, sellerId: string) => {
71
+ if (!skuId || !sellerId) return;
72
+ const button = this as HTMLButtonElement;
73
+ button.dataset.loading = "true";
74
+ await globalThis.window.STOREFRONT.CART.addToCart({
75
+ orderItems: [{ id: skuId, quantity: 1, seller: sellerId }],
76
+ });
77
+ button.dataset.loading = "false";
78
+ },
79
+ productSku,
80
+ sellerId ?? "1",
81
+ )}
82
+ >
83
+ Add to bag
84
+ </button>
85
+ ```
86
+
87
+ ### After
88
+
89
+ ```tsx
90
+ import { useState } from "react";
91
+ import { useCart } from "~/hooks/useCart";
92
+
93
+ export default function AddToBagButton({ productSku, sellerId }) {
94
+ const { addItems } = useCart();
95
+ const [loading, setLoading] = useState(false);
96
+
97
+ return (
98
+ <button
99
+ data-loading={loading}
100
+ onClick={async () => {
101
+ if (!productSku || !sellerId) return;
102
+ setLoading(true);
103
+ try {
104
+ await addItems({
105
+ orderItems: [{ id: productSku, quantity: 1, seller: sellerId ?? "1" }],
106
+ });
107
+ } finally {
108
+ setLoading(false);
109
+ }
110
+ }}
111
+ >
112
+ Add to bag
113
+ </button>
114
+ );
115
+ }
116
+ ```
117
+
118
+ ### Gotchas
119
+
120
+ - `globalThis.window.STOREFRONT.CART.*` → the platform hook
121
+ (`useCart` from `~/hooks/useCart`, which delegates to `createUseCart`
122
+ from `@decocms/apps/vtex/hooks/createUseCart`). Do not reach for
123
+ globals.
124
+ - `this as HTMLButtonElement` → use a `useRef`, or read from the
125
+ React event (`(e) => e.currentTarget`).
126
+ - `button.dataset.loading = "true"` direct DOM mutation breaks
127
+ hydration on F5 and React Compiler optimisations. Use state.
128
+ - The `useScript` import (`@deco/deco/hooks` or
129
+ `@decocms/start/sdk/useScript`) becomes unused — remove it.
130
+
131
+ ## Pattern 2 — `boost`
132
+
133
+ Trivial. `hx-boost` enabled SPA-style navigation on regular `<a>`
134
+ elements. TanStack Router's `<Link>` does this by default.
135
+
136
+ ### Before
137
+
138
+ ```tsx
139
+ <a hx-boost="true" href="/produto/foo">Foo</a>
140
+ ```
141
+
142
+ ### After
143
+
144
+ ```tsx
145
+ import { Link } from "@tanstack/react-router";
146
+
147
+ <Link to="/produto/$slug" params={{ slug: "foo" }}>Foo</Link>;
148
+ ```
149
+
150
+ ### Gotchas
151
+
152
+ - For unparameterised links, `<Link to="/produto/foo">` works without
153
+ `params`.
154
+ - `<a>` tags that aren't `hx-boost`-ed should also become `<Link>`
155
+ if they target a route in the app — but that's covered by
156
+ `references/navigation.md`, not by this skill.
157
+
158
+ ## Pattern 3 — `click-swap`
159
+
160
+ The dominant button-driven shape: a button with `hx-get` (or
161
+ `hx-post`) + `hx-target` + `hx-swap` that fetches a server-rendered
162
+ section and swaps it into a target element.
163
+
164
+ ### Before
165
+
166
+ ```tsx
167
+ <button
168
+ type="button"
169
+ hx-target={`#${VIEW_CONTENT_ID}`}
170
+ hx-swap="innerHTML transition:true"
171
+ hx-trigger="click"
172
+ hx-get={useSection({
173
+ props: {
174
+ viewConfig: {
175
+ view: viewIds.RECEIVE_ACCESS_CODE_FOR_PASSWORD,
176
+ currentEmail: props?.currentEmail,
177
+ },
178
+ },
179
+ })}
180
+ hx-indicator="this"
181
+ >
182
+ Forgot password
183
+ </button>
184
+ ```
185
+
186
+ ### Two After options
187
+
188
+ **Option A — local state machine** (when the swap stays inside one
189
+ component tree, e.g. a multi-step login form). Each `view` becomes a
190
+ discriminator in component state, each section becomes a sub-component.
191
+
192
+ ```tsx
193
+ import { useState } from "react";
194
+
195
+ type View =
196
+ | { name: "EMAIL_PWD" }
197
+ | { name: "RECEIVE_CODE_FOR_PWD"; currentEmail?: string }
198
+ | { name: "FORGOTTEN_PWD" };
199
+
200
+ export function LoginFlow({ initialEmail }: { initialEmail?: string }) {
201
+ const [view, setView] = useState<View>({ name: "EMAIL_PWD" });
202
+
203
+ if (view.name === "EMAIL_PWD") {
204
+ return (
205
+ <EmailAndPassword
206
+ currentEmail={initialEmail}
207
+ onForgotPassword={() =>
208
+ setView({
209
+ name: "RECEIVE_CODE_FOR_PWD",
210
+ currentEmail: initialEmail,
211
+ })
212
+ }
213
+ />
214
+ );
215
+ }
216
+ if (view.name === "RECEIVE_CODE_FOR_PWD") {
217
+ return <ReceiveAccessCode currentEmail={view.currentEmail} />;
218
+ }
219
+ return <ForgottenPassword />;
220
+ }
221
+ ```
222
+
223
+ The "Forgot password" button becomes:
224
+
225
+ ```tsx
226
+ <button type="button" onClick={onForgotPassword}>Forgot password</button>
227
+ ```
228
+
229
+ **Option B — sub-route** (when the swap should be URL-addressable,
230
+ e.g. cart, address-book pages, account sections that benefit from
231
+ deep-links).
232
+
233
+ ```tsx
234
+ // routes/account/forgot-password.tsx
235
+ import { createFileRoute } from "@tanstack/react-router";
236
+
237
+ export const Route = createFileRoute("/account/forgot-password")({
238
+ component: ForgottenPasswordPage,
239
+ });
240
+ ```
241
+
242
+ Button becomes:
243
+
244
+ ```tsx
245
+ <Link to="/account/forgot-password">Forgot password</Link>
246
+ ```
247
+
248
+ ### Choose between A and B
249
+
250
+ - **B** if the new "view" is a meaningful URL (search filters, tabs,
251
+ multi-step flows worth bookmarking, account pages).
252
+ - **A** if the swap is incidental UI state (modals, dropdowns,
253
+ step-flows that should not survive reload).
254
+
255
+ ## Pattern 4 — `form-swap`
256
+
257
+ Form posts that swap the result back into a target. Same surface as
258
+ click-swap but on a `<form>` and triggered by `submit`.
259
+
260
+ ### Before
261
+
262
+ ```tsx
263
+ <form
264
+ hx-target={`#${VIEW_CONTENT_ID}`}
265
+ hx-swap="innerHTML transition:true show:window:top"
266
+ hx-trigger="submit"
267
+ hx-post={useSection({
268
+ props: {
269
+ viewConfig: { view: viewIds.EMAIL_AND_PASSWORD },
270
+ action: actionIds.CLASSIC_SIGN_IN,
271
+ },
272
+ })}
273
+ hx-indicator=".submit"
274
+ >
275
+ <input name="email" type="email" required />
276
+ <input name="password" type="password" required />
277
+ <button type="submit" data-test-id="login-submit">Sign In</button>
278
+ </form>
279
+ ```
280
+
281
+ ### After
282
+
283
+ ```tsx
284
+ import { useMutation } from "@tanstack/react-query";
285
+ import { invoke } from "~/server/invoke";
286
+ import { useState } from "react";
287
+
288
+ export function EmailAndPassword({ onSuccess }: { onSuccess: (user: User) => void }) {
289
+ const [error, setError] = useState<string | null>(null);
290
+
291
+ const signIn = useMutation({
292
+ mutationFn: (input: { email: string; password: string }) =>
293
+ invoke({ "vtex.actions.auth/classicSignIn": input }),
294
+ onSuccess: (user) => onSuccess(user),
295
+ onError: (e) => setError(e.message),
296
+ });
297
+
298
+ return (
299
+ <form
300
+ onSubmit={(e) => {
301
+ e.preventDefault();
302
+ const data = new FormData(e.currentTarget);
303
+ signIn.mutate({
304
+ email: String(data.get("email")),
305
+ password: String(data.get("password")),
306
+ });
307
+ }}
308
+ >
309
+ <input name="email" type="email" required />
310
+ <input name="password" type="password" required />
311
+ <button type="submit" disabled={signIn.isPending}>
312
+ {signIn.isPending ? "Signing in…" : "Sign In"}
313
+ </button>
314
+ {error && <p>{error}</p>}
315
+ </form>
316
+ );
317
+ }
318
+ ```
319
+
320
+ ### Gotchas
321
+
322
+ - `hx-post={useSection(...)}` posted to a Deco section that ran a
323
+ server-side handler. The TanStack equivalent is **either** a
324
+ server function (`createServerFn`) **or** an action exposed via
325
+ `invoke` over `@decocms/apps`. Pick the one whose business logic
326
+ already lives there.
327
+ - `hx-indicator=".submit"` → button `disabled={mutation.isPending}`
328
+ + visible "Signing in…" text. No CSS class plumbing.
329
+ - Validation errors that the section returned via re-render: now
330
+ surface via `mutation.onError` + state.
331
+
332
+ ## Pattern 5 — `auto-fetch`
333
+
334
+ A fetch attribute fires automatically on a non-click trigger:
335
+ `keyup`, `intersect`, `revealed`, `load`, or `every:Xs`. Most often
336
+ seen on `<input>` for search-as-you-type.
337
+
338
+ ### Before — search-as-you-type
339
+
340
+ ```tsx
341
+ <input
342
+ id={searchInputId}
343
+ name="q"
344
+ type="text"
345
+ hx-sync="this:replace"
346
+ hx-swap="innerHTML transition:true"
347
+ hx-target={`#${searchResultsId}`}
348
+ hx-post={useComponent(Suggestions, { id })}
349
+ hx-trigger="keyup changed delay:200ms"
350
+ class="…"
351
+ />
352
+ <div id={searchResultsId}></div>
353
+ ```
354
+
355
+ ### After
356
+
357
+ ```tsx
358
+ import { useState, useDeferredValue } from "react";
359
+ import { useQuery, keepPreviousData } from "@tanstack/react-query";
360
+ import { invoke } from "~/server/invoke";
361
+
362
+ export function SearchInput() {
363
+ const [term, setTerm] = useState("");
364
+ const debouncedTerm = useDeferredValue(term);
365
+
366
+ const suggestions = useQuery({
367
+ queryKey: ["search-suggestions", debouncedTerm],
368
+ queryFn: () =>
369
+ invoke({ "vtex.loaders.intelligentSearch/suggestions": { term: debouncedTerm } }),
370
+ enabled: debouncedTerm.length >= 2,
371
+ placeholderData: keepPreviousData,
372
+ staleTime: 30_000,
373
+ });
374
+
375
+ return (
376
+ <>
377
+ <input
378
+ type="text"
379
+ value={term}
380
+ onChange={(e) => setTerm(e.target.value)}
381
+ placeholder="Search"
382
+ />
383
+ <div>{suggestions.data?.map((s) => <SuggestionRow key={s.id} item={s} />)}</div>
384
+ </>
385
+ );
386
+ }
387
+ ```
388
+
389
+ ### Before — intersection-triggered (lazy load row)
390
+
391
+ ```tsx
392
+ <div hx-trigger="intersect once" hx-get={useComponent(MoreItems)} hx-swap="outerHTML"></div>
393
+ ```
394
+
395
+ ### After
396
+
397
+ ```tsx
398
+ import { useEffect, useRef, useState } from "react";
399
+
400
+ export function MoreItemsLoader() {
401
+ const ref = useRef<HTMLDivElement>(null);
402
+ const [loaded, setLoaded] = useState(false);
403
+ const [items, setItems] = useState<Item[] | null>(null);
404
+
405
+ useEffect(() => {
406
+ if (loaded || !ref.current) return;
407
+ const obs = new IntersectionObserver(
408
+ async ([entry]) => {
409
+ if (!entry.isIntersecting) return;
410
+ obs.disconnect();
411
+ setLoaded(true);
412
+ setItems(await invoke({ "site.loaders/moreItems": {} }));
413
+ },
414
+ { rootMargin: "100px" },
415
+ );
416
+ obs.observe(ref.current);
417
+ return () => obs.disconnect();
418
+ }, [loaded]);
419
+
420
+ if (items) return <>{items.map((i) => <Row key={i.id} item={i} />)}</>;
421
+ return <div ref={ref} />;
422
+ }
423
+ ```
424
+
425
+ ### Gotchas
426
+
427
+ - `hx-sync="this:replace"` — the htmx-side concurrency control —
428
+ becomes "useQuery dedupes identical query keys + replaces results
429
+ on key change" automatically. No equivalent flag needed.
430
+ - `keyup changed delay:200ms` → `useDeferredValue` gives concurrent
431
+ rendering, or fall back to a manual debounce hook if you want a
432
+ fixed delay.
433
+ - Single-`once` triggers should set a flag on first intersection
434
+ and disconnect the observer (see `loaded` above).
435
+
436
+ ## Pattern 6 — `oob-swap`
437
+
438
+ Out-of-band swaps don't have a clean React equivalent. They were
439
+ htmx's mechanism for a server response to update **multiple
440
+ disconnected DOM nodes** in one request — e.g. update the cart
441
+ indicator in the header AND the cart drawer body from a single
442
+ "add to cart" response.
443
+
444
+ There's no codemod here. Two refactor patterns:
445
+
446
+ ### Refactor A — global state
447
+ Lift the data the OOB nodes read into a shared store (`@tanstack/store`).
448
+ The mutation writes to the store; the consumers re-render naturally.
449
+ This is the right choice 90 % of the time.
450
+
451
+ ### Refactor B — broadcast event
452
+ For cases where one component must trigger a side effect in another
453
+ without a shared data shape: dispatch a custom event on `window`,
454
+ listen for it in the consumer's `useEffect`. This is the htmx
455
+ `hx-swap-oob` pattern, just done with browser primitives.
456
+
457
+ ```tsx
458
+ // Producer
459
+ window.dispatchEvent(new CustomEvent("cart:item-added", { detail: { itemId } }));
460
+
461
+ // Consumer
462
+ useEffect(() => {
463
+ const handler = (e: Event) => { /* react to e.detail */ };
464
+ window.addEventListener("cart:item-added", handler);
465
+ return () => window.removeEventListener("cart:item-added", handler);
466
+ }, []);
467
+ ```
468
+
469
+ Prefer A. Resort to B only when the producer/consumer can't share a
470
+ parent.
471
+
472
+ ## Pattern 7 — `unmatched`
473
+
474
+ Cases the analyzer didn't fit cleanly. Read each call site. Common
475
+ shapes:
476
+
477
+ - `<div hx-indicator="…">` standalone — passive marker that some
478
+ *other* element shows as a loading indicator. The other element
479
+ is the real interactivity site; rewrite there. The `hx-indicator`
480
+ div becomes plain JSX gated on a state boolean.
481
+ - `htmx-request:` Tailwind variants (`htmx-request:loading`,
482
+ `[.htmx-request>&]:hidden`) — these toggle styles based on
483
+ htmx's own request lifecycle. Replace with `data-[loading=true]:`
484
+ variants driven by your component state.
485
+ - `hx-confirm`, `hx-prompt` — synchronous browser dialogs. Use the
486
+ site's modal system, or `window.confirm()` if it was always a
487
+ trivial native dialog.
488
+ - Any `hx-*` attribute on a Deco component import path
489
+ (`<Accordion.Trigger hx-on-click={...}>`) — the component itself
490
+ spreads attrs onto a child element. Lift the handler into a prop.
491
+
492
+ ## Verification
493
+
494
+ After the rewrite, the post-cleanup audit's
495
+ **`htmx-residue`** rule (Wave 13-C) reports any remaining `hx-*` in
496
+ the migrated `src/`. A site is "rewrite-complete" when `htmx-residue
497
+ == 0` for non-test files.
498
+
499
+ ```bash
500
+ npx -p @decocms/start deco-post-cleanup --strict
501
+ # exit 2 if any hx-* attributes survive in src/
502
+ ```
503
+
504
+ You can also re-run the analyzer on the migrated tree to verify
505
+ zero hits:
506
+
507
+ ```bash
508
+ npx -p @decocms/start deco-htmx-analyze
509
+ # expects: ✓ No hx-* attributes found.
510
+ ```
511
+
512
+ ## Real-world signal — als-storefront
513
+
514
+ Initial inventory across 133 source files, 210 occurrences:
515
+
516
+ | Bucket | Count | % |
517
+ |---|---:|---:|
518
+ | event-handler | 88 | 42 % |
519
+ | click-swap | 64 | 30 % |
520
+ | form-swap | 20 | 10 % |
521
+ | auto-fetch | 9 | 4 % |
522
+ | oob-swap | 8 | 4 % |
523
+ | unmatched | 21 | 10 % |
524
+
525
+ 86 % of occurrences fall in codemod-able buckets (event-handler,
526
+ click-swap, form-swap, auto-fetch). Wave 14 ships codemods for those
527
+ four; the rest stay manual.
@@ -17,7 +17,8 @@ of which sections below actually apply to your codebase:
17
17
  npx -p @decocms/start deco-post-cleanup
18
18
 
19
19
  # Auto-apply mechanical fixes for the safe rules, then report what's left.
20
- # Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types.
20
+ # Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types,
21
+ # vtex-shim-regression (swap subset), obsolete-vite-plugins.
21
22
  # Other rules stay detect-only — they require human judgment.
22
23
  npx -p @decocms/start deco-post-cleanup --fix
23
24
 
@@ -29,18 +30,23 @@ npx -p @decocms/start deco-post-cleanup --json
29
30
  ```
30
31
 
31
32
  The audit covers all 7 rules below and prints the exact file path +
32
- suggested fix for each finding. With `--fix`, the three safe rules
33
- auto-apply (`rm` for dead files, regex-anchored import rewrites for
34
- shadowed shims). The output explicitly tags rules that require manual
35
- work as `(0 fixed, manual)`, so you always know what's left after
36
- auto-fix runs.
33
+ suggested fix for each finding. With `--fix`, the safe rules
34
+ auto-apply: `rm` for dead files, regex-anchored import rewrites for
35
+ shadowed shims (`local-widgets-types`, `dead-runtime-shim`), the swap
36
+ subset of `vtex-shim-regression`, and JS-aware removal of obsolete
37
+ inline plugin literals from `vite.config.ts`. The output explicitly
38
+ tags rules that require manual work as `(0 fixed, manual)`, so you
39
+ always know what's left after auto-fix runs.
37
40
 
38
41
  Real-world signal: on baggagio, `--fix` produced a byte-identical
39
42
  diff to the manual cleanup PR a human had just made (45 files,
40
43
  +45/-53). On casaevideo-storefront (production), the audit caught
41
44
  six silent VTEX shim regressions that no `tsc --noEmit` run can
42
- detect — those still require manual cleanup until rule 5 gains a
43
- per-shim mapping table.
45
+ detect — `--fix` covers the swap subset of those automatically since
46
+ `>= 2.16.0`. On the same site's `vite.config.ts`, `--fix` removes
47
+ both obsolete inline plugins (`site-manual-chunks` +
48
+ `deco-stub-meta-gen`) cleanly — ~74 LOC / 2.5 KB gone, attached
49
+ comments included.
44
50
 
45
51
  ## 1. Delete unused `src/lib/*` shims
46
52
 
@@ -115,8 +121,14 @@ The framework's `decoVitePlugin()` now handles both:
115
121
  old split caused circular-dep load-order crashes — every site overrode it)
116
122
  - `meta.gen.{json,ts}` is stubbed on the client by default
117
123
 
118
- Delete both inline plugins from the site's `vite.config.ts`. Verify the
119
- production build still succeeds (`vite build` in the site repo).
124
+ Delete both inline plugins from the site's `vite.config.ts`. Since
125
+ `@decocms/start >= 2.19.0`, `deco-post-cleanup --fix` does this for
126
+ you — it walks the AST with brace-balanced parsing (template literals
127
+ and nested `{}` inside `config()`/`load()` bodies don't trip it up),
128
+ removes the literal **plus its trailing comma + attached `// ...`
129
+ comment block**, and is idempotent (rerunning is a no-op). Block
130
+ comments are left alone. Verify the production build still succeeds
131
+ (`vite build` in the site repo).
120
132
 
121
133
  ## 3. Drop the `runtime.ts` `invoke` shim
122
134
 
@@ -347,7 +359,46 @@ rm src/types/widgets.ts
347
359
  Confirm `tsc --noEmit` is still clean — the framework version is a
348
360
  strict superset of what the migration script generated.
349
361
 
350
- ## 7. Search for orphan `TODO: move into framework` comments
362
+ ## 7. Verify no leftover HTMX residue in `src/`
363
+
364
+ For sites that came from a Fresh codebase using HTMX (`@deco/htmx`,
365
+ `hx-*` attributes on JSX elements), the migration to TanStack Start
366
+ requires **rewriting** every HTMX interaction to React state +
367
+ event handlers + `useNavigate()`/sub-routes. The framework
368
+ intentionally ships **no** HTMX runtime — leaving `hx-*` attributes
369
+ in the migrated `src/` tree means the corresponding interaction is
370
+ silently dead.
371
+
372
+ The audit's `htmx-residue` rule scans every `*.{ts,tsx}` under `src/`
373
+ (excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) for any
374
+ remaining `hx-*` attribute, classifies each occurrence into one of
375
+ seven categories (`event-handler`, `form-swap`, `click-swap`,
376
+ `auto-fetch`, `oob-swap`, `boost`, `unmatched`), and emits one
377
+ warning per file with a category breakdown:
378
+
379
+ ```
380
+ [WARNING] src/components/AddToBagButton.tsx:14 — 1 hx-* element(s) — event-handler=1
381
+ fix: Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md
382
+ (run `deco-htmx-analyze` for the per-category breakdown)
383
+ ```
384
+
385
+ The rule is intentionally **detect-only**:
386
+
387
+ - The rewrites are non-mechanical — choosing between a local state
388
+ machine, a sub-route, a React form action, or a platform hook
389
+ (e.g. `useCart`) varies per call site and depends on the data
390
+ flow, not just the attribute cluster.
391
+ - The companion CLI `deco-htmx-analyze` produces a richer inventory
392
+ (top tags, sample line numbers, JSON output) when you need to
393
+ triage hundreds of occurrences across a large repo.
394
+ - The `references/htmx-rewrite.md` skill is the per-pattern
395
+ playbook with before/after code for each of the seven categories.
396
+
397
+ In `--strict` mode any residue exits 2 — wire that into CI once a
398
+ site has finished its HTMX rewrite to prevent regressions sneaking
399
+ back in via copy-paste from a Fresh source.
400
+
401
+ ## 8. Search for orphan `TODO: move into framework` comments
351
402
 
352
403
  Real sites accumulate `TODO` comments like `// TODO: move into decoVitePlugin
353
404
  in next @decocms/start release`. These are roadmap items the framework
@@ -364,7 +415,7 @@ For each hit, decide:
364
415
 
365
416
  ## Verification checklist
366
417
 
367
- After completing 1-7:
418
+ After completing 1-8:
368
419
 
369
420
  - [ ] `npm run typecheck` baseline matches pre-cleanup count (no new errors)
370
421
  - [ ] `npm run dev` starts and `/`, `/some-pdp/p`, `/s?q=foo` all render