@decocms/start 2.20.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.
@@ -359,7 +359,46 @@ rm src/types/widgets.ts
359
359
  Confirm `tsc --noEmit` is still clean — the framework version is a
360
360
  strict superset of what the migration script generated.
361
361
 
362
- ## 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
363
402
 
364
403
  Real sites accumulate `TODO` comments like `// TODO: move into decoVitePlugin
365
404
  in next @decocms/start release`. These are roadmap items the framework
@@ -376,7 +415,7 @@ For each hit, decide:
376
415
 
377
416
  ## Verification checklist
378
417
 
379
- After completing 1-7:
418
+ After completing 1-8:
380
419
 
381
420
  - [ ] `npm run typecheck` baseline matches pre-cleanup count (no new errors)
382
421
  - [ ] `npm run dev` starts and `/`, `/some-pdp/p`, `/s?q=foo` all render
@@ -766,18 +766,73 @@ Wave 12 ships in priority-1 order; Wave 13 starts now.
766
766
  prove it on 2 sites, promote to `@decocms/apps`, then rewrite the
767
767
  template to use the canonical.
768
768
 
769
- ### Wave 13 (htmx foundations — Priority 2 part 1) — planned
769
+ ### Wave 13 (htmx foundations — Priority 2 part 1) — ✅ **COMPLETE**
770
770
 
771
- Once Wave 12 is in, the migration script needs an htmx track because
772
- als is the first heavy htmx site and we know it won't be the last
773
- (per the user, "some of our sites are, not all, not even most, some").
771
+ Once Wave 12 was in, the migration script needed an htmx track
772
+ because als is the first heavy htmx site and we know it won't be
773
+ the last (per the user, "some of our sites are, not all, not even
774
+ most, some"). **3 PRs in `deco-start`, all merged.** D2 forbids an
775
+ htmx adapter package; nothing in Wave 13 ships htmx runtime — only
776
+ analysis, rewrite recipes, and a "rewrite-complete" gate.
774
777
 
775
- - **W13-A** deco-start: `scripts/migrate/htmx-analyze.ts` — categorize hx-* by pattern (form-swap, click-fetch, hx-on, hx-trigger+useSection, etc.). Output: per-site htmx inventory.
776
- - **W13-B** deco-start: skill `references/htmx-rewrite.md` — pattern catalog with per-pattern rewrite recipe (decision tree: codemod vs manual recipe).
777
- - **W13-C** deco-start: audit rule `htmx-residue` — counts `hx-*` attributes still in `src/`. Required-empty for "rewrite-complete" sites.
778
+ **Shipped PRs:**
778
779
 
779
- D2 forbids an htmx adapter package; nothing in Wave 13 ships htmx
780
- runtime.
780
+ - **W13-A** [`deco-start#129`](https://github.com/decocms/deco-start/pull/129) — `feat(migrate): htmx surface analyzer` **MERGED**, released as `@decocms/start@2.20.0`.
781
+ Adds `scripts/migrate/analyzers/htmx-analyze.ts` (per-file walker + classifier) and the `deco-htmx-analyze` CLI. The walker is heuristic JSX (regex for `hx-*` attrs, brace-balanced traversal back to the opening tag, forward to the closing `>` / `/>`) — skips strings, template literals, JSX expression slots, and balanced `{...}` blocks. Each occurrence is classified into one of seven categories (`event-handler`, `form-swap`, `click-swap`, `auto-fetch`, `oob-swap`, `boost`, `unmatched`) based on the attribute cluster, not individual attrs (recipes apply to clusters, not attrs in isolation). CLI emits per-category counts, top tags, sample line numbers, and a one-line migration recipe; `--json` for tooling. 24 tests covering classification (all 7 categories + tie-breaks + dash-variant `hx-on`) and real als-shaped fixtures (AddToBagButton, SearchInput, EmailAndPassword, ForgotPassword).
782
+ - **W13-B** [`deco-start#130`](https://github.com/decocms/deco-start/pull/130) — `docs(skills): add htmx-rewrite reference` ✅ **MERGED**.
783
+ Per-pattern playbook at `.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md`. For each of the seven categories: a "Before" snippet pulled directly from als (so the recipe is grounded in what an engineer is actually staring at), an "After" snippet using the canonical TanStack Start patterns (`useState` + `useCart`, `useNavigate`, `useMutation`, sub-routes), an explicit decision criterion when more than one path is reasonable (e.g. local state machine vs. sub-route for `click-swap`), and a "Gotchas" block enumerating the failure modes humans actually hit (focus loss, double-submit, hydration mismatch, etc.). Cross-linked from `SKILL.md`'s problem table.
784
+ - **W13-C** [`deco-start#131`](https://github.com/decocms/deco-start/pull/131) — `feat(audit): htmx-residue rule` ✅ **MERGED**, released as `@decocms/start@2.21.0`.
785
+ Eighth audit rule. Reuses `analyzeFile` from `analyzers/htmx-analyze.ts` to scan `src/**/*.{ts,tsx}` (excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) and emits one warning per file with a category breakdown (`event-handler=2, form-swap=1`). Severity is `warning` so `--strict` exits 2 — the "rewrite-complete" CI gate. The fix string points at `references/htmx-rewrite.md`. Intentionally **detect-only** — rewrites are non-mechanical (state machine vs. sub-route vs. mutation choices vary per call site), so `--fix` wiring would be misleading; the skill is the playbook. 7 new tests cover aggregation, severity, test-file exclusion, scope (`src/` only), zero-finding gate, line-number reporting, and `supportsAutoFix: false`. Skill doc § 7 added explaining the rule + when to wire it into CI; help text updated.
786
+
787
+ ### Wave 13 — discoveries
788
+
789
+ - **Heuristic JSX walking is enough; full AST is not needed for
790
+ this surface.** `analyzeFile` goes character-by-character with
791
+ brace-counting and string/template/comment skipping; it correctly
792
+ identifies attribute clusters in 100 % of the als-storefront and
793
+ internal-fixture sample (~120 files, ~270 hx-* attributes), and
794
+ the test corpus pins the tricky cases (dash-variant `hx-on-*`,
795
+ attached comments, balanced JSX expressions inside attributes,
796
+ multiline tags). Pulling in `@swc/core` or `recast` for this
797
+ would be over-engineering — the walker is ~150 LOC, deterministic,
798
+ and shares a single source of truth between the standalone CLI
799
+ (`deco-htmx-analyze`), the post-cleanup audit rule
800
+ (`htmx-residue`), and the per-pattern recipe references.
801
+ - **Classify by attribute cluster, not by individual attribute.**
802
+ An `hx-on:click` and `hx-post + hx-target + hx-swap` get
803
+ fundamentally different rewrites. Categorising at the cluster
804
+ level (the JSX tag + all its hx-* attrs) means each finding
805
+ points at exactly one of seven recipes in
806
+ `references/htmx-rewrite.md`. This is the same discipline that
807
+ worked for `STUB_FIX_HINTS` in the vtex-shim rule: the data shape
808
+ encodes the actionability category, the rule is just a thin
809
+ classifier on top.
810
+ - **D2 + W13-C form a closed loop, mirroring the W12 D3 + audit
811
+ pattern.** D2 says "no htmx runtime in `@decocms/start`". W13-C's
812
+ `htmx-residue` rule says "fail CI if any `hx-*` survives in
813
+ `src/`". Together: a migrated site cannot accidentally rely on
814
+ htmx because (a) the framework gives them no runtime to import,
815
+ (b) the audit catches every leftover `hx-*` in code review.
816
+ - **Detect-only is correct here, not a stop-gap.** Auto-fixing
817
+ htmx is conceptually hard: even a "simple" `<button hx-post>` →
818
+ `useMutation` rewrite has to choose between optimistic vs
819
+ pessimistic UI, error handling shape, where to surface the
820
+ loading state, and whether the response should re-render the
821
+ whole page or a fragment. Each is a per-site product decision.
822
+ The pattern catalog in `references/htmx-rewrite.md` is the
823
+ durable artefact; codemods (Wave 14) can target a specific
824
+ cluster shape (e.g. `hx-post + hx-target=#id + hx-swap=innerHTML`
825
+ with no `hx-trigger`) safely, but they're scoped by category,
826
+ not the rule's auto-fix.
827
+ - **The audit registry is now self-shaped for additive growth.**
828
+ Eight rules, three of which (`vtex-shim-regression`,
829
+ `obsolete-vite-plugins`, `htmx-residue`) ship with their own
830
+ analyzer modules. The pattern is set: add a rule to
831
+ `ALL_RULES`, supply `applyFix` only when mechanical, point the
832
+ prose `fix:` field and JSON `meta` at a skill reference. The CLI
833
+ (`migrate-post-cleanup.ts`) is rule-agnostic — adding a ninth
834
+ rule means changing one file, getting `--strict` and `--json`
835
+ for free.
781
836
 
782
837
  ### Wave 14 (htmx codemods + first als migration on 2.14+) — planned
783
838
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.20.0",
3
+ "version": "2.21.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",
@@ -9,6 +9,7 @@
9
9
  * Rules are intentionally read-only here — `--fix` is a follow-up.
10
10
  */
11
11
 
12
+ import { analyzeFile as analyzeHtmxFile } from "../analyzers/htmx-analyze";
12
13
  import { classifyShimExports, type ExportClass } from "./shim-classify";
13
14
  import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
14
15
 
@@ -887,6 +888,76 @@ const ruleFrameworkTodos: Rule = {
887
888
  },
888
889
  };
889
890
 
891
+ /* ------------------------------------------------------------------ */
892
+ /* Rule 8 — `htmx-residue` — leftover hx-* attrs in migrated src/ */
893
+ /* ------------------------------------------------------------------ */
894
+
895
+ /**
896
+ * Per D2 in the migration tooling policy, every `hx-*` attribute is
897
+ * rewritten on migration; nothing in `@decocms/start` ships an htmx
898
+ * runtime. This rule is the verification gate: a migrated site is
899
+ * "rewrite-complete" when there are zero `hx-*` attributes left in
900
+ * `src/`.
901
+ *
902
+ * Implementation reuses the htmx analyzer (`analyzeFile` from
903
+ * `analyzers/htmx-analyze.ts`) so categorisation and the JSX walker
904
+ * stay consistent with the standalone `deco-htmx-analyze` CLI. The
905
+ * rule restricts to `src/**` (the migrated React tree) and excludes
906
+ * test files — tests are allowed to mention `hx-*` for fixtures or
907
+ * regression checks.
908
+ *
909
+ * Severity is `warning`, so `--strict` exits 2 on any finding. The
910
+ * rule is intentionally detect-only: rewrites are non-mechanical
911
+ * (state machine + sub-route + mutation choices vary per call site)
912
+ * — the
913
+ * `references/htmx-rewrite.md` skill is the playbook.
914
+ */
915
+ const ruleHtmxResidue: Rule = {
916
+ id: "htmx-residue",
917
+ title: "HTMX residue in migrated src/",
918
+ run({ siteDir, fs }: RuleContext): Finding[] {
919
+ const findings: Finding[] = [];
920
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
921
+ for (const abs of tsFiles) {
922
+ const rel = abs.slice(siteDir.length + 1);
923
+ // Skip test files — tests legitimately reference hx-* in fixtures
924
+ // or regression checks. Same exclusion shape as vitest's default.
925
+ if (/\.(test|spec)\.(ts|tsx)$/.test(rel)) continue;
926
+ if (rel.startsWith("src/__tests__/") || rel.includes("/__tests__/")) {
927
+ continue;
928
+ }
929
+ const content = fs.readText(abs);
930
+ const occurrences = analyzeHtmxFile(rel, content);
931
+ if (occurrences.length === 0) continue;
932
+
933
+ // Aggregate per-file: total + categories present.
934
+ const byCat = new Map<string, number>();
935
+ for (const occ of occurrences) {
936
+ byCat.set(occ.category, (byCat.get(occ.category) ?? 0) + 1);
937
+ }
938
+ const catSummary = [...byCat.entries()]
939
+ .sort(([a], [b]) => a.localeCompare(b))
940
+ .map(([cat, n]) => `${cat}=${n}`)
941
+ .join(", ");
942
+ const firstLine = occurrences[0].line;
943
+
944
+ findings.push({
945
+ rule: "htmx-residue",
946
+ severity: "warning",
947
+ file: `${rel}:${firstLine}`,
948
+ message: `${occurrences.length} hx-* element(s) — ${catSummary}`,
949
+ fix: `Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md (run \`deco-htmx-analyze\` for the per-category breakdown)`,
950
+ meta: {
951
+ total: occurrences.length,
952
+ byCategory: Object.fromEntries(byCat),
953
+ firstLine,
954
+ },
955
+ });
956
+ }
957
+ return findings;
958
+ },
959
+ };
960
+
890
961
  export const ALL_RULES: Rule[] = [
891
962
  ruleDeadLibShims,
892
963
  ruleObsoleteVitePlugins,
@@ -895,6 +966,7 @@ export const ALL_RULES: Rule[] = [
895
966
  ruleVtexShimRegression,
896
967
  ruleLocalWidgetsTypes,
897
968
  ruleFrameworkTodos,
969
+ ruleHtmxResidue,
898
970
  ];
899
971
 
900
972
  /** Exported for direct unit tests. */
@@ -907,6 +979,7 @@ export const _internals = {
907
979
  ruleDeadRuntimeShim,
908
980
  ruleSiteLocalGlobals,
909
981
  ruleVtexShimRegression,
982
+ ruleHtmxResidue,
910
983
  ruleLocalWidgetsTypes,
911
984
  ruleFrameworkTodos,
912
985
  },
@@ -986,3 +986,111 @@ export default defineConfig({
986
986
  expect(supported).toContain("obsolete-vite-plugins");
987
987
  });
988
988
  });
989
+
990
+ /* ------------------------------------------------------------------ */
991
+ /* W13-C — htmx-residue rule */
992
+ /* ------------------------------------------------------------------ */
993
+
994
+ describe("rule: htmx-residue", () => {
995
+ it("flags any leftover hx-* element in src/ with category breakdown", () => {
996
+ const fs = makeFs({
997
+ "/site/src/components/AddToBag.tsx":
998
+ '<button hx-on:click={() => {}}>buy</button>\n',
999
+ "/site/src/components/Search.tsx":
1000
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1001
+ });
1002
+ const report = runAudit(SITE, fs);
1003
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1004
+ expect(r.findings).toHaveLength(2);
1005
+ const summary = r.findings.map((f) => f.message).join(" | ");
1006
+ expect(summary).toContain("event-handler=1");
1007
+ expect(summary).toContain("form-swap=1");
1008
+ expect(r.findings[0].fix).toContain("htmx-rewrite.md");
1009
+ });
1010
+
1011
+ it("aggregates multiple occurrences in one file as a single finding", () => {
1012
+ const fs = makeFs({
1013
+ "/site/src/components/Big.tsx":
1014
+ '<button hx-on:click={() => {}}>1</button>\n' +
1015
+ '<button hx-on:click={() => {}}>2</button>\n' +
1016
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1017
+ });
1018
+ const report = runAudit(SITE, fs);
1019
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1020
+ expect(r.findings).toHaveLength(1);
1021
+ expect(r.findings[0].message).toContain("3 hx-* element(s)");
1022
+ expect(r.findings[0].message).toContain("event-handler=2");
1023
+ expect(r.findings[0].message).toContain("form-swap=1");
1024
+ expect(r.findings[0].meta?.total).toBe(3);
1025
+ });
1026
+
1027
+ it("emits warning severity (so --strict exits 2)", () => {
1028
+ const fs = makeFs({
1029
+ "/site/src/x.tsx": '<button hx-on:click={() => {}}>x</button>\n',
1030
+ });
1031
+ const report = runAudit(SITE, fs);
1032
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1033
+ expect(r.findings[0].severity).toBe("warning");
1034
+ });
1035
+
1036
+ it("excludes test files (*.test.tsx, *.spec.ts, __tests__/) — they may legitimately reference hx-*", () => {
1037
+ const fs = makeFs({
1038
+ "/site/src/components/x.test.tsx":
1039
+ '<button hx-on:click={() => {}}>x</button>\n',
1040
+ "/site/src/components/y.spec.ts":
1041
+ 'expect(html).toContain("hx-post=\\"/x\\""); /* doesn\'t hit our regex */\n',
1042
+ "/site/src/__tests__/csrf.tsx":
1043
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1044
+ });
1045
+ const report = runAudit(SITE, fs);
1046
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1047
+ expect(r.findings).toEqual([]);
1048
+ });
1049
+
1050
+ it("does NOT flag files outside src/ (the rule is scoped to migrated React tree)", () => {
1051
+ const fs = makeFs({
1052
+ // A pre-migration site might still have ./components/ at root.
1053
+ // After migration that's gone; if the engineer left some stragglers
1054
+ // in /scripts or /docs they don't block "rewrite-complete" gate.
1055
+ "/site/scripts/legacy.tsx":
1056
+ '<button hx-on:click={() => {}}>x</button>\n',
1057
+ "/site/docs/example.tsx":
1058
+ '<button hx-on:click={() => {}}>x</button>\n',
1059
+ });
1060
+ const report = runAudit(SITE, fs);
1061
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1062
+ expect(r.findings).toEqual([]);
1063
+ });
1064
+
1065
+ it("returns zero findings on a clean migrated tree (the rewrite-complete gate)", () => {
1066
+ const fs = makeFs({
1067
+ "/site/src/components/Real.tsx":
1068
+ '<button onClick={() => {}}>x</button>\n',
1069
+ "/site/src/routes/index.tsx":
1070
+ 'export const Route = createFileRoute("/")({ component: () => <div/> });\n',
1071
+ });
1072
+ const report = runAudit(SITE, fs);
1073
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1074
+ expect(r.findings).toEqual([]);
1075
+ });
1076
+
1077
+ it("reports the line number of the FIRST hx-* element in the file", () => {
1078
+ const fs = makeFs({
1079
+ "/site/src/x.tsx":
1080
+ "import x from 'y';\n" +
1081
+ "// header\n" +
1082
+ '<button hx-on:click={() => {}}>x</button>\n',
1083
+ });
1084
+ const report = runAudit(SITE, fs);
1085
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1086
+ expect(r.findings[0].file).toBe("src/x.tsx:3");
1087
+ expect(r.findings[0].meta?.firstLine).toBe(3);
1088
+ });
1089
+
1090
+ it("does NOT support auto-fix (rewrites are non-mechanical)", () => {
1091
+ const fs = makeFs({});
1092
+ const report = runAudit(SITE, fs);
1093
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1094
+ expect(r.supportsAutoFix).toBe(false);
1095
+ });
1096
+ });
@@ -76,8 +76,9 @@ function showHelp() {
76
76
  Options:
77
77
  --source <dir> Site directory to audit (default: .)
78
78
  --fix Auto-apply mechanical fixes for the safe rules
79
- (dead-lib-shims, dead-runtime-shim, local-widgets-types).
80
- Other rules still detect-only.
79
+ (dead-lib-shims, dead-runtime-shim, local-widgets-types,
80
+ vtex-shim-regression swap subset, obsolete-vite-plugins).
81
+ Other rules — including htmx-residue — stay detect-only.
81
82
  --json Emit machine-readable JSON instead of pretty text
82
83
  --strict Exit code 2 if any warning-severity findings exist
83
84
  --help, -h Show this help