@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.
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -0
- package/.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md +527 -0
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +63 -12
- package/MIGRATION_TOOLING_PLAN.md +142 -31
- package/package.json +3 -2
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/post-cleanup/rules.ts +73 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +108 -0
- package/scripts/migrate-post-cleanup.ts +3 -2
|
@@ -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
|
|
33
|
-
auto-apply
|
|
34
|
-
shadowed shims
|
|
35
|
-
|
|
36
|
-
|
|
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 —
|
|
43
|
-
|
|
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`.
|
|
119
|
-
|
|
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.
|
|
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-
|
|
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
|