@decocms/start 2.20.0 → 2.22.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 +548 -0
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +41 -2
- package/MIGRATION_TOOLING_PLAN.md +214 -16
- package/package.json +1 -1
- package/scripts/migrate/phase-transform.ts +7 -1
- package/scripts/migrate/post-cleanup/rules.ts +73 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +108 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -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,548 @@
|
|
|
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
|
+
> **Codemod available** (since `@decocms/start >= 2.21.0`). The
|
|
66
|
+
> migration script's `transforms` pipeline now runs
|
|
67
|
+
> `htmx-on-event-rename`, which mechanically rewrites
|
|
68
|
+
> `hx-on:click={…}` → `onClick={…}` (and every other standard DOM
|
|
69
|
+
> event in the [STANDARD_EVENT_MAP](https://github.com/decocms/deco-start/blob/main/scripts/migrate/transforms/htmx-on-events.ts)
|
|
70
|
+
> table) for both colon and dash variants. Handler bodies are
|
|
71
|
+
> preserved verbatim; if the body references Fresh-only globals
|
|
72
|
+
> (`useScript(…)`, `globalThis.window.STOREFRONT`, `STOREFRONT.…`),
|
|
73
|
+
> the codemod injects a single MIGRATION TODO comment at the top
|
|
74
|
+
> of the file pointing back here. **htmx lifecycle events**
|
|
75
|
+
> (`hx-on:htmx-config-request`, `hx-on-htmx-before-request`, etc.)
|
|
76
|
+
> and unknown custom events (`hx-on:my-custom-thing`) are left
|
|
77
|
+
> alone — those need manual rewrite, and the `htmx-residue` audit
|
|
78
|
+
> rule catches them.
|
|
79
|
+
>
|
|
80
|
+
> Smoke result on als-storefront (754 files): codemod renames 98
|
|
81
|
+
> `hx-on:*` attributes across 71 files; 67 of those files (94 %)
|
|
82
|
+
> get the body-TODO. Engineers still own the body rewrite below;
|
|
83
|
+
> the codemod just removes the dead `hx-*` attribute name so the
|
|
84
|
+
> file compiles in React.
|
|
85
|
+
|
|
86
|
+
### Before
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<button
|
|
90
|
+
hx-on:click={useScript(
|
|
91
|
+
async (skuId: string, sellerId: string) => {
|
|
92
|
+
if (!skuId || !sellerId) return;
|
|
93
|
+
const button = this as HTMLButtonElement;
|
|
94
|
+
button.dataset.loading = "true";
|
|
95
|
+
await globalThis.window.STOREFRONT.CART.addToCart({
|
|
96
|
+
orderItems: [{ id: skuId, quantity: 1, seller: sellerId }],
|
|
97
|
+
});
|
|
98
|
+
button.dataset.loading = "false";
|
|
99
|
+
},
|
|
100
|
+
productSku,
|
|
101
|
+
sellerId ?? "1",
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
Add to bag
|
|
105
|
+
</button>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### After
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { useState } from "react";
|
|
112
|
+
import { useCart } from "~/hooks/useCart";
|
|
113
|
+
|
|
114
|
+
export default function AddToBagButton({ productSku, sellerId }) {
|
|
115
|
+
const { addItems } = useCart();
|
|
116
|
+
const [loading, setLoading] = useState(false);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
data-loading={loading}
|
|
121
|
+
onClick={async () => {
|
|
122
|
+
if (!productSku || !sellerId) return;
|
|
123
|
+
setLoading(true);
|
|
124
|
+
try {
|
|
125
|
+
await addItems({
|
|
126
|
+
orderItems: [{ id: productSku, quantity: 1, seller: sellerId ?? "1" }],
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
setLoading(false);
|
|
130
|
+
}
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
Add to bag
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Gotchas
|
|
140
|
+
|
|
141
|
+
- `globalThis.window.STOREFRONT.CART.*` → the platform hook
|
|
142
|
+
(`useCart` from `~/hooks/useCart`, which delegates to `createUseCart`
|
|
143
|
+
from `@decocms/apps/vtex/hooks/createUseCart`). Do not reach for
|
|
144
|
+
globals.
|
|
145
|
+
- `this as HTMLButtonElement` → use a `useRef`, or read from the
|
|
146
|
+
React event (`(e) => e.currentTarget`).
|
|
147
|
+
- `button.dataset.loading = "true"` direct DOM mutation breaks
|
|
148
|
+
hydration on F5 and React Compiler optimisations. Use state.
|
|
149
|
+
- The `useScript` import (`@deco/deco/hooks` or
|
|
150
|
+
`@decocms/start/sdk/useScript`) becomes unused — remove it.
|
|
151
|
+
|
|
152
|
+
## Pattern 2 — `boost`
|
|
153
|
+
|
|
154
|
+
Trivial. `hx-boost` enabled SPA-style navigation on regular `<a>`
|
|
155
|
+
elements. TanStack Router's `<Link>` does this by default.
|
|
156
|
+
|
|
157
|
+
### Before
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
<a hx-boost="true" href="/produto/foo">Foo</a>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### After
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import { Link } from "@tanstack/react-router";
|
|
167
|
+
|
|
168
|
+
<Link to="/produto/$slug" params={{ slug: "foo" }}>Foo</Link>;
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Gotchas
|
|
172
|
+
|
|
173
|
+
- For unparameterised links, `<Link to="/produto/foo">` works without
|
|
174
|
+
`params`.
|
|
175
|
+
- `<a>` tags that aren't `hx-boost`-ed should also become `<Link>`
|
|
176
|
+
if they target a route in the app — but that's covered by
|
|
177
|
+
`references/navigation.md`, not by this skill.
|
|
178
|
+
|
|
179
|
+
## Pattern 3 — `click-swap`
|
|
180
|
+
|
|
181
|
+
The dominant button-driven shape: a button with `hx-get` (or
|
|
182
|
+
`hx-post`) + `hx-target` + `hx-swap` that fetches a server-rendered
|
|
183
|
+
section and swaps it into a target element.
|
|
184
|
+
|
|
185
|
+
### Before
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
hx-target={`#${VIEW_CONTENT_ID}`}
|
|
191
|
+
hx-swap="innerHTML transition:true"
|
|
192
|
+
hx-trigger="click"
|
|
193
|
+
hx-get={useSection({
|
|
194
|
+
props: {
|
|
195
|
+
viewConfig: {
|
|
196
|
+
view: viewIds.RECEIVE_ACCESS_CODE_FOR_PASSWORD,
|
|
197
|
+
currentEmail: props?.currentEmail,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
})}
|
|
201
|
+
hx-indicator="this"
|
|
202
|
+
>
|
|
203
|
+
Forgot password
|
|
204
|
+
</button>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Two After options
|
|
208
|
+
|
|
209
|
+
**Option A — local state machine** (when the swap stays inside one
|
|
210
|
+
component tree, e.g. a multi-step login form). Each `view` becomes a
|
|
211
|
+
discriminator in component state, each section becomes a sub-component.
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
import { useState } from "react";
|
|
215
|
+
|
|
216
|
+
type View =
|
|
217
|
+
| { name: "EMAIL_PWD" }
|
|
218
|
+
| { name: "RECEIVE_CODE_FOR_PWD"; currentEmail?: string }
|
|
219
|
+
| { name: "FORGOTTEN_PWD" };
|
|
220
|
+
|
|
221
|
+
export function LoginFlow({ initialEmail }: { initialEmail?: string }) {
|
|
222
|
+
const [view, setView] = useState<View>({ name: "EMAIL_PWD" });
|
|
223
|
+
|
|
224
|
+
if (view.name === "EMAIL_PWD") {
|
|
225
|
+
return (
|
|
226
|
+
<EmailAndPassword
|
|
227
|
+
currentEmail={initialEmail}
|
|
228
|
+
onForgotPassword={() =>
|
|
229
|
+
setView({
|
|
230
|
+
name: "RECEIVE_CODE_FOR_PWD",
|
|
231
|
+
currentEmail: initialEmail,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (view.name === "RECEIVE_CODE_FOR_PWD") {
|
|
238
|
+
return <ReceiveAccessCode currentEmail={view.currentEmail} />;
|
|
239
|
+
}
|
|
240
|
+
return <ForgottenPassword />;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The "Forgot password" button becomes:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
<button type="button" onClick={onForgotPassword}>Forgot password</button>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Option B — sub-route** (when the swap should be URL-addressable,
|
|
251
|
+
e.g. cart, address-book pages, account sections that benefit from
|
|
252
|
+
deep-links).
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
// routes/account/forgot-password.tsx
|
|
256
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
257
|
+
|
|
258
|
+
export const Route = createFileRoute("/account/forgot-password")({
|
|
259
|
+
component: ForgottenPasswordPage,
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Button becomes:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<Link to="/account/forgot-password">Forgot password</Link>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Choose between A and B
|
|
270
|
+
|
|
271
|
+
- **B** if the new "view" is a meaningful URL (search filters, tabs,
|
|
272
|
+
multi-step flows worth bookmarking, account pages).
|
|
273
|
+
- **A** if the swap is incidental UI state (modals, dropdowns,
|
|
274
|
+
step-flows that should not survive reload).
|
|
275
|
+
|
|
276
|
+
## Pattern 4 — `form-swap`
|
|
277
|
+
|
|
278
|
+
Form posts that swap the result back into a target. Same surface as
|
|
279
|
+
click-swap but on a `<form>` and triggered by `submit`.
|
|
280
|
+
|
|
281
|
+
### Before
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
<form
|
|
285
|
+
hx-target={`#${VIEW_CONTENT_ID}`}
|
|
286
|
+
hx-swap="innerHTML transition:true show:window:top"
|
|
287
|
+
hx-trigger="submit"
|
|
288
|
+
hx-post={useSection({
|
|
289
|
+
props: {
|
|
290
|
+
viewConfig: { view: viewIds.EMAIL_AND_PASSWORD },
|
|
291
|
+
action: actionIds.CLASSIC_SIGN_IN,
|
|
292
|
+
},
|
|
293
|
+
})}
|
|
294
|
+
hx-indicator=".submit"
|
|
295
|
+
>
|
|
296
|
+
<input name="email" type="email" required />
|
|
297
|
+
<input name="password" type="password" required />
|
|
298
|
+
<button type="submit" data-test-id="login-submit">Sign In</button>
|
|
299
|
+
</form>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### After
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { useMutation } from "@tanstack/react-query";
|
|
306
|
+
import { invoke } from "~/server/invoke";
|
|
307
|
+
import { useState } from "react";
|
|
308
|
+
|
|
309
|
+
export function EmailAndPassword({ onSuccess }: { onSuccess: (user: User) => void }) {
|
|
310
|
+
const [error, setError] = useState<string | null>(null);
|
|
311
|
+
|
|
312
|
+
const signIn = useMutation({
|
|
313
|
+
mutationFn: (input: { email: string; password: string }) =>
|
|
314
|
+
invoke({ "vtex.actions.auth/classicSignIn": input }),
|
|
315
|
+
onSuccess: (user) => onSuccess(user),
|
|
316
|
+
onError: (e) => setError(e.message),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<form
|
|
321
|
+
onSubmit={(e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
const data = new FormData(e.currentTarget);
|
|
324
|
+
signIn.mutate({
|
|
325
|
+
email: String(data.get("email")),
|
|
326
|
+
password: String(data.get("password")),
|
|
327
|
+
});
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
<input name="email" type="email" required />
|
|
331
|
+
<input name="password" type="password" required />
|
|
332
|
+
<button type="submit" disabled={signIn.isPending}>
|
|
333
|
+
{signIn.isPending ? "Signing in…" : "Sign In"}
|
|
334
|
+
</button>
|
|
335
|
+
{error && <p>{error}</p>}
|
|
336
|
+
</form>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Gotchas
|
|
342
|
+
|
|
343
|
+
- `hx-post={useSection(...)}` posted to a Deco section that ran a
|
|
344
|
+
server-side handler. The TanStack equivalent is **either** a
|
|
345
|
+
server function (`createServerFn`) **or** an action exposed via
|
|
346
|
+
`invoke` over `@decocms/apps`. Pick the one whose business logic
|
|
347
|
+
already lives there.
|
|
348
|
+
- `hx-indicator=".submit"` → button `disabled={mutation.isPending}`
|
|
349
|
+
+ visible "Signing in…" text. No CSS class plumbing.
|
|
350
|
+
- Validation errors that the section returned via re-render: now
|
|
351
|
+
surface via `mutation.onError` + state.
|
|
352
|
+
|
|
353
|
+
## Pattern 5 — `auto-fetch`
|
|
354
|
+
|
|
355
|
+
A fetch attribute fires automatically on a non-click trigger:
|
|
356
|
+
`keyup`, `intersect`, `revealed`, `load`, or `every:Xs`. Most often
|
|
357
|
+
seen on `<input>` for search-as-you-type.
|
|
358
|
+
|
|
359
|
+
### Before — search-as-you-type
|
|
360
|
+
|
|
361
|
+
```tsx
|
|
362
|
+
<input
|
|
363
|
+
id={searchInputId}
|
|
364
|
+
name="q"
|
|
365
|
+
type="text"
|
|
366
|
+
hx-sync="this:replace"
|
|
367
|
+
hx-swap="innerHTML transition:true"
|
|
368
|
+
hx-target={`#${searchResultsId}`}
|
|
369
|
+
hx-post={useComponent(Suggestions, { id })}
|
|
370
|
+
hx-trigger="keyup changed delay:200ms"
|
|
371
|
+
class="…"
|
|
372
|
+
/>
|
|
373
|
+
<div id={searchResultsId}></div>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### After
|
|
377
|
+
|
|
378
|
+
```tsx
|
|
379
|
+
import { useState, useDeferredValue } from "react";
|
|
380
|
+
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|
381
|
+
import { invoke } from "~/server/invoke";
|
|
382
|
+
|
|
383
|
+
export function SearchInput() {
|
|
384
|
+
const [term, setTerm] = useState("");
|
|
385
|
+
const debouncedTerm = useDeferredValue(term);
|
|
386
|
+
|
|
387
|
+
const suggestions = useQuery({
|
|
388
|
+
queryKey: ["search-suggestions", debouncedTerm],
|
|
389
|
+
queryFn: () =>
|
|
390
|
+
invoke({ "vtex.loaders.intelligentSearch/suggestions": { term: debouncedTerm } }),
|
|
391
|
+
enabled: debouncedTerm.length >= 2,
|
|
392
|
+
placeholderData: keepPreviousData,
|
|
393
|
+
staleTime: 30_000,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<>
|
|
398
|
+
<input
|
|
399
|
+
type="text"
|
|
400
|
+
value={term}
|
|
401
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
402
|
+
placeholder="Search"
|
|
403
|
+
/>
|
|
404
|
+
<div>{suggestions.data?.map((s) => <SuggestionRow key={s.id} item={s} />)}</div>
|
|
405
|
+
</>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Before — intersection-triggered (lazy load row)
|
|
411
|
+
|
|
412
|
+
```tsx
|
|
413
|
+
<div hx-trigger="intersect once" hx-get={useComponent(MoreItems)} hx-swap="outerHTML"></div>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### After
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
import { useEffect, useRef, useState } from "react";
|
|
420
|
+
|
|
421
|
+
export function MoreItemsLoader() {
|
|
422
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
423
|
+
const [loaded, setLoaded] = useState(false);
|
|
424
|
+
const [items, setItems] = useState<Item[] | null>(null);
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (loaded || !ref.current) return;
|
|
428
|
+
const obs = new IntersectionObserver(
|
|
429
|
+
async ([entry]) => {
|
|
430
|
+
if (!entry.isIntersecting) return;
|
|
431
|
+
obs.disconnect();
|
|
432
|
+
setLoaded(true);
|
|
433
|
+
setItems(await invoke({ "site.loaders/moreItems": {} }));
|
|
434
|
+
},
|
|
435
|
+
{ rootMargin: "100px" },
|
|
436
|
+
);
|
|
437
|
+
obs.observe(ref.current);
|
|
438
|
+
return () => obs.disconnect();
|
|
439
|
+
}, [loaded]);
|
|
440
|
+
|
|
441
|
+
if (items) return <>{items.map((i) => <Row key={i.id} item={i} />)}</>;
|
|
442
|
+
return <div ref={ref} />;
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Gotchas
|
|
447
|
+
|
|
448
|
+
- `hx-sync="this:replace"` — the htmx-side concurrency control —
|
|
449
|
+
becomes "useQuery dedupes identical query keys + replaces results
|
|
450
|
+
on key change" automatically. No equivalent flag needed.
|
|
451
|
+
- `keyup changed delay:200ms` → `useDeferredValue` gives concurrent
|
|
452
|
+
rendering, or fall back to a manual debounce hook if you want a
|
|
453
|
+
fixed delay.
|
|
454
|
+
- Single-`once` triggers should set a flag on first intersection
|
|
455
|
+
and disconnect the observer (see `loaded` above).
|
|
456
|
+
|
|
457
|
+
## Pattern 6 — `oob-swap`
|
|
458
|
+
|
|
459
|
+
Out-of-band swaps don't have a clean React equivalent. They were
|
|
460
|
+
htmx's mechanism for a server response to update **multiple
|
|
461
|
+
disconnected DOM nodes** in one request — e.g. update the cart
|
|
462
|
+
indicator in the header AND the cart drawer body from a single
|
|
463
|
+
"add to cart" response.
|
|
464
|
+
|
|
465
|
+
There's no codemod here. Two refactor patterns:
|
|
466
|
+
|
|
467
|
+
### Refactor A — global state
|
|
468
|
+
Lift the data the OOB nodes read into a shared store (`@tanstack/store`).
|
|
469
|
+
The mutation writes to the store; the consumers re-render naturally.
|
|
470
|
+
This is the right choice 90 % of the time.
|
|
471
|
+
|
|
472
|
+
### Refactor B — broadcast event
|
|
473
|
+
For cases where one component must trigger a side effect in another
|
|
474
|
+
without a shared data shape: dispatch a custom event on `window`,
|
|
475
|
+
listen for it in the consumer's `useEffect`. This is the htmx
|
|
476
|
+
`hx-swap-oob` pattern, just done with browser primitives.
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
// Producer
|
|
480
|
+
window.dispatchEvent(new CustomEvent("cart:item-added", { detail: { itemId } }));
|
|
481
|
+
|
|
482
|
+
// Consumer
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
const handler = (e: Event) => { /* react to e.detail */ };
|
|
485
|
+
window.addEventListener("cart:item-added", handler);
|
|
486
|
+
return () => window.removeEventListener("cart:item-added", handler);
|
|
487
|
+
}, []);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Prefer A. Resort to B only when the producer/consumer can't share a
|
|
491
|
+
parent.
|
|
492
|
+
|
|
493
|
+
## Pattern 7 — `unmatched`
|
|
494
|
+
|
|
495
|
+
Cases the analyzer didn't fit cleanly. Read each call site. Common
|
|
496
|
+
shapes:
|
|
497
|
+
|
|
498
|
+
- `<div hx-indicator="…">` standalone — passive marker that some
|
|
499
|
+
*other* element shows as a loading indicator. The other element
|
|
500
|
+
is the real interactivity site; rewrite there. The `hx-indicator`
|
|
501
|
+
div becomes plain JSX gated on a state boolean.
|
|
502
|
+
- `htmx-request:` Tailwind variants (`htmx-request:loading`,
|
|
503
|
+
`[.htmx-request>&]:hidden`) — these toggle styles based on
|
|
504
|
+
htmx's own request lifecycle. Replace with `data-[loading=true]:`
|
|
505
|
+
variants driven by your component state.
|
|
506
|
+
- `hx-confirm`, `hx-prompt` — synchronous browser dialogs. Use the
|
|
507
|
+
site's modal system, or `window.confirm()` if it was always a
|
|
508
|
+
trivial native dialog.
|
|
509
|
+
- Any `hx-*` attribute on a Deco component import path
|
|
510
|
+
(`<Accordion.Trigger hx-on-click={...}>`) — the component itself
|
|
511
|
+
spreads attrs onto a child element. Lift the handler into a prop.
|
|
512
|
+
|
|
513
|
+
## Verification
|
|
514
|
+
|
|
515
|
+
After the rewrite, the post-cleanup audit's
|
|
516
|
+
**`htmx-residue`** rule (Wave 13-C) reports any remaining `hx-*` in
|
|
517
|
+
the migrated `src/`. A site is "rewrite-complete" when `htmx-residue
|
|
518
|
+
== 0` for non-test files.
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
npx -p @decocms/start deco-post-cleanup --strict
|
|
522
|
+
# exit 2 if any hx-* attributes survive in src/
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
You can also re-run the analyzer on the migrated tree to verify
|
|
526
|
+
zero hits:
|
|
527
|
+
|
|
528
|
+
```bash
|
|
529
|
+
npx -p @decocms/start deco-htmx-analyze
|
|
530
|
+
# expects: ✓ No hx-* attributes found.
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
## Real-world signal — als-storefront
|
|
534
|
+
|
|
535
|
+
Initial inventory across 133 source files, 210 occurrences:
|
|
536
|
+
|
|
537
|
+
| Bucket | Count | % |
|
|
538
|
+
|---|---:|---:|
|
|
539
|
+
| event-handler | 88 | 42 % |
|
|
540
|
+
| click-swap | 64 | 30 % |
|
|
541
|
+
| form-swap | 20 | 10 % |
|
|
542
|
+
| auto-fetch | 9 | 4 % |
|
|
543
|
+
| oob-swap | 8 | 4 % |
|
|
544
|
+
| unmatched | 21 | 10 % |
|
|
545
|
+
|
|
546
|
+
86 % of occurrences fall in codemod-able buckets (event-handler,
|
|
547
|
+
click-swap, form-swap, auto-fetch). Wave 14 ships codemods for those
|
|
548
|
+
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.
|
|
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-
|
|
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
|