@decocms/start 0.39.0 → 0.40.1
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-migrate-script/SKILL.md +434 -0
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +382 -0
- package/.agents/skills/deco-to-tanstack-migration/references/admin-cms.md +154 -0
- package/{.cursor/skills/deco-async-rendering-site-guide/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/async-rendering.md} +296 -31
- package/.agents/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
- package/.agents/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
- package/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +156 -0
- package/.agents/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
- package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +13 -0
- package/{.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/hydration-fixes.md} +139 -4
- package/.agents/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
- package/{.cursor/skills/deco-islands-migration/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/islands.md} +0 -14
- package/.agents/skills/deco-to-tanstack-migration/references/jsx-migration.md +80 -0
- package/.agents/skills/deco-to-tanstack-migration/references/matchers.md +1064 -0
- package/{.cursor/skills/deco-tanstack-navigation/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/navigation.md} +1 -16
- package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
- package/.agents/skills/deco-to-tanstack-migration/references/react-hooks-patterns.md +142 -0
- package/.agents/skills/deco-to-tanstack-migration/references/react-signals-state.md +72 -0
- package/{.cursor/skills/deco-tanstack-search/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/search.md} +1 -13
- package/.agents/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
- package/{.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/storefront-patterns.md} +1 -137
- package/.agents/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
- package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +165 -0
- package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +209 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/router.md +96 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
- package/.agents/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
- package/README.md +45 -0
- package/package.json +1 -1
- package/src/routes/cmsRoute.ts +7 -4
- package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +0 -270
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: deco-tanstack-navigation
|
|
3
|
-
description: "Complete guide for migrating Fresh/Deno navigation to TanStack Router in Deco storefronts. Covers: replacing <a href> with <Link>, prefetch strategies for instant navigation, type-safe params, activeProps for menus, search state as URL source of truth, SSR-first SEO architecture, loaderDeps for reactive search params, form submissions via server actions, and programmatic preloading. Use when porting any Deco site from Fresh to TanStack Start."
|
|
4
|
-
---
|
|
5
1
|
|
|
6
2
|
# Deco TanStack Navigation Migration
|
|
7
3
|
|
|
8
4
|
Complete playbook for replacing Fresh/Deno navigation with TanStack Router in Deco storefronts. Goes beyond simple `<a>` to `<Link>` — covers the full power of the router to build sites that feel like native apps while keeping SSR-first SEO.
|
|
9
5
|
|
|
10
|
-
## When to Use This
|
|
6
|
+
## When to Use This Reference
|
|
11
7
|
|
|
12
8
|
- Migrating a Fresh/Deno storefront to TanStack Start
|
|
13
9
|
- Links cause full page reloads instead of SPA transitions
|
|
@@ -668,14 +664,3 @@ rg '<form[^>]*action=' src/ --glob '*.tsx' | rg -v 'onSubmit'
|
|
|
668
664
|
| No prefetch | `preload="intent"` | Data ready before click |
|
|
669
665
|
| `req.url` in loader | `loaderDeps + deps.search` | Reactive to URL changes |
|
|
670
666
|
| `router.push(url)` | `router.preloadRoute + navigate` | Preload then navigate |
|
|
671
|
-
|
|
672
|
-
---
|
|
673
|
-
|
|
674
|
-
## Related Skills
|
|
675
|
-
|
|
676
|
-
| Skill | Purpose |
|
|
677
|
-
|-------|---------|
|
|
678
|
-
| `deco-to-tanstack-migration` | Full migration playbook (imports, signals, framework) |
|
|
679
|
-
| `deco-islands-migration` | Eliminating the islands/ directory |
|
|
680
|
-
| `deco-tanstack-storefront-patterns` | Runtime patterns and fixes post-migration |
|
|
681
|
-
| `deco-storefront-test-checklist` | Context-aware QA checklist generation |
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Platform Hooks Migration
|
|
2
|
+
|
|
3
|
+
Platform hooks (useCart, useUser, useWishlist) are the most complex migration target because they have real business logic.
|
|
4
|
+
|
|
5
|
+
## Strategy
|
|
6
|
+
|
|
7
|
+
All hooks are **site-local**. No Vite alias tricks. No compat layers.
|
|
8
|
+
|
|
9
|
+
- Active platform hooks (VTEX for this store) -> `~/hooks/useCart.ts` with real implementation
|
|
10
|
+
- Inactive platform hooks (Wake, Shopify, etc.) -> `~/hooks/platform/{name}.ts` with no-op stubs
|
|
11
|
+
- Auth hooks -> `~/hooks/useUser.ts`, `~/hooks/useWishlist.ts`
|
|
12
|
+
|
|
13
|
+
## VTEX useCart (Real Implementation)
|
|
14
|
+
|
|
15
|
+
### Why Server Functions Are Required
|
|
16
|
+
|
|
17
|
+
The storefront domain (e.g., `my-store.deco.site`) differs from the VTEX checkout domain (`account.vtexcommercestable.com.br`). Direct browser `fetch()` calls are blocked by CORS. Additionally, VTEX API credentials (`AppKey`/`AppToken`) must stay server-side.
|
|
18
|
+
|
|
19
|
+
Use TanStack Start `createServerFn` to create server-side proxy functions that the client hook calls transparently.
|
|
20
|
+
|
|
21
|
+
### Server Functions (~/lib/vtex-cart-server.ts)
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
25
|
+
|
|
26
|
+
const ACCOUNT = "myaccount";
|
|
27
|
+
const API_KEY = process.env.VTEX_APP_KEY!;
|
|
28
|
+
const API_TOKEN = process.env.VTEX_APP_TOKEN!;
|
|
29
|
+
|
|
30
|
+
export const getOrCreateCart = createServerFn({ method: "GET" })
|
|
31
|
+
.validator((orderFormId: string) => orderFormId)
|
|
32
|
+
.handler(async ({ data: orderFormId }) => {
|
|
33
|
+
const url = orderFormId
|
|
34
|
+
? `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`
|
|
35
|
+
: `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm`;
|
|
36
|
+
const res = await fetch(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"X-VTEX-API-AppKey": API_KEY,
|
|
41
|
+
"X-VTEX-API-AppToken": API_TOKEN,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({ expectedOrderFormSections: ["items", "totalizers", "shippingData", "clientPreferencesData", "storePreferencesData", "marketingData"] }),
|
|
44
|
+
});
|
|
45
|
+
return res.json();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const addItemsToCart = createServerFn({ method: "POST" })
|
|
49
|
+
.validator((data: { orderFormId: string; items: any[] }) => data)
|
|
50
|
+
.handler(async ({ data }) => {
|
|
51
|
+
const res = await fetch(
|
|
52
|
+
`https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${data.orderFormId}/items`,
|
|
53
|
+
{
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json", "X-VTEX-API-AppKey": API_KEY, "X-VTEX-API-AppToken": API_TOKEN },
|
|
56
|
+
body: JSON.stringify({ orderItems: data.items }),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
return res.json();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const updateCartItems = createServerFn({ method: "POST" })
|
|
63
|
+
.validator((data: { orderFormId: string; items: any[] }) => data)
|
|
64
|
+
.handler(async ({ data }) => {
|
|
65
|
+
const res = await fetch(
|
|
66
|
+
`https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${data.orderFormId}/items/update`,
|
|
67
|
+
{
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json", "X-VTEX-API-AppKey": API_KEY, "X-VTEX-API-AppToken": API_TOKEN },
|
|
70
|
+
body: JSON.stringify({ orderItems: data.items }),
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
return res.json();
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Hook (~/hooks/useCart.ts)
|
|
78
|
+
|
|
79
|
+
Key design decisions:
|
|
80
|
+
- **Module-level singleton state** shared across all component instances
|
|
81
|
+
- **Pub/sub pattern** (`_listeners` Set) for notifying React components of changes
|
|
82
|
+
- **Cookie-based session**: reads/writes `checkout.vtex.com__orderFormId` on the **client** side (not VTEX's domain cookie)
|
|
83
|
+
- Returns `cart` and `loading` with `.value` getter/setter for backward compat with Preact-era components
|
|
84
|
+
- Lazy initialization: cart is fetched on first component mount, not on module load
|
|
85
|
+
- Exports `itemToAnalyticsItem` for cart-specific analytics mapping
|
|
86
|
+
|
|
87
|
+
### Cross-Domain Checkout
|
|
88
|
+
|
|
89
|
+
The minicart's "Finalizar Compra" button must link to the VTEX checkout domain with the `orderFormId` as a query parameter — the VTEX domain can't read the storefront's cookies:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const checkoutUrl = `https://secure.${STORE_DOMAIN}/checkout/?orderFormId=${orderFormId}`;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### VTEX Types (~/types/vtex.ts)
|
|
96
|
+
|
|
97
|
+
Site-local types for VTEX-specific structures:
|
|
98
|
+
- `OrderFormItem`, `SimulationOrderForm`, `Sla`, `SKU`, `VtexProduct`
|
|
99
|
+
|
|
100
|
+
## Inactive Platform Stubs
|
|
101
|
+
|
|
102
|
+
For non-VTEX platforms, create minimal no-op files:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// ~/hooks/platform/wake.ts
|
|
106
|
+
export function useCart() {
|
|
107
|
+
return {
|
|
108
|
+
cart: { value: null },
|
|
109
|
+
loading: { value: false },
|
|
110
|
+
addItem: async (_params: any) => {},
|
|
111
|
+
updateItems: async (_params: any) => {},
|
|
112
|
+
removeItem: async (_index: any) => {},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useUser() {
|
|
117
|
+
return {
|
|
118
|
+
user: { value: null as { email?: string; name?: string } | null },
|
|
119
|
+
loading: { value: false },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useWishlist() {
|
|
124
|
+
return {
|
|
125
|
+
loading: { value: false },
|
|
126
|
+
addItem: async (_props: any) => {},
|
|
127
|
+
removeItem: async (_props: any) => {},
|
|
128
|
+
getItem: (_props: any) => undefined as any,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Create similar stubs for: `shopify.ts`, `linx.ts`, `vnda.ts`, `nuvemshop.ts`.
|
|
134
|
+
|
|
135
|
+
Match the return shape to what each platform's AddToCartButton expects (some use `addItem`, others `addItems`).
|
|
136
|
+
|
|
137
|
+
## Import Rewrites
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
sed -i '' 's|from "apps/vtex/hooks/useCart.ts"|from "~/hooks/useCart"|g'
|
|
141
|
+
sed -i '' 's|from "apps/vtex/hooks/useUser.ts"|from "~/hooks/useUser"|g'
|
|
142
|
+
sed -i '' 's|from "apps/vtex/hooks/useWishlist.ts"|from "~/hooks/useWishlist"|g'
|
|
143
|
+
sed -i '' 's|from "apps/vtex/utils/types.ts"|from "~/types/vtex"|g'
|
|
144
|
+
sed -i '' 's|from "apps/shopify/hooks/useCart.ts"|from "~/hooks/platform/shopify"|g'
|
|
145
|
+
sed -i '' 's|from "apps/wake/hooks/useCart.ts"|from "~/hooks/platform/wake"|g'
|
|
146
|
+
# etc. for all platforms
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Verification
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
grep -r 'from "apps/' src/ --include='*.ts' --include='*.tsx'
|
|
153
|
+
# Should return ZERO matches
|
|
154
|
+
```
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# React Hooks Patterns
|
|
2
|
+
|
|
3
|
+
> useEffect anti-patterns, useQuery, useMemo, lazy useState, Rules of Hooks.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## 2. useEffect Doesn't Run on Server
|
|
7
|
+
|
|
8
|
+
Components relying on `useEffect` to populate state will render empty on SSR.
|
|
9
|
+
|
|
10
|
+
**Fix**: Use TanStack route loaders or section loaders for server-side data.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## 33. usePartialSection is a No-Op
|
|
14
|
+
|
|
15
|
+
**Severity**: HIGH — tab switching, filter toggling, any partial section re-render breaks silently
|
|
16
|
+
|
|
17
|
+
Deco's `usePartialSection` hook re-renders a section with new props by making a server request for just that section's HTML. In the React port, this mechanism doesn't exist — `usePartialSection` returns empty data attributes.
|
|
18
|
+
|
|
19
|
+
**Symptom**: Tabbed product shelves only show the first tab. Clicking other tabs does nothing. Filter visibility toggles don't work. Any component using `usePartialSection` for dynamic props appears frozen.
|
|
20
|
+
|
|
21
|
+
**Fix**: Replace with React `useState` for client-side state switching. If all tab data is already loaded server-side (common for tabbed shelves where the loader fetches all tabs), just switch between the data client-side:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// Before: relies on usePartialSection to re-render with new tabIndex
|
|
25
|
+
<button {...usePartialSection({ props: { tabIndex: i } })}>
|
|
26
|
+
|
|
27
|
+
// After: React state-driven tab switching
|
|
28
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
29
|
+
<button onClick={() => setActiveTab(i)}>
|
|
30
|
+
// Then render tabs[activeTab].products instead of tabs[tabIndex].products
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For filter toggles, replace `usePartialSection({ props: { openFilter: !openFilter } })` with `useState`:
|
|
34
|
+
```typescript
|
|
35
|
+
const [openFilter, setOpenFilter] = useState(true);
|
|
36
|
+
<button onClick={() => setOpenFilter(!openFilter)}>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## 46. useEffect for Client-Side Data Fetching → useQuery
|
|
41
|
+
|
|
42
|
+
**Severity**: LOW (correctness) / MEDIUM (UX) — `useEffect` fetches are error-prone, miss loading/error states, and don't cache results.
|
|
43
|
+
|
|
44
|
+
`useEffect` is correct ONLY for true side effects (localStorage reads, subscriptions, DOM manipulation). For any data fetch that runs client-side, `@tanstack/react-query` (already installed) is the right tool.
|
|
45
|
+
|
|
46
|
+
**Pattern to replace**:
|
|
47
|
+
```tsx
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [error, setError] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!condition) { setLoading(true); return; }
|
|
53
|
+
if (dependency) {
|
|
54
|
+
fetch(...)
|
|
55
|
+
.then(res => setError(res.error))
|
|
56
|
+
.catch(console.error)
|
|
57
|
+
.finally(() => setLoading(false));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}, [condition]);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Replace with**:
|
|
65
|
+
```tsx
|
|
66
|
+
import { useQuery } from "@tanstack/react-query";
|
|
67
|
+
|
|
68
|
+
const { isFetching, data } = useQuery({
|
|
69
|
+
queryKey: ["key", dependency],
|
|
70
|
+
queryFn: () => fetch(...),
|
|
71
|
+
enabled: condition && !!dependency,
|
|
72
|
+
staleTime: 0, // use when result should never be cached (e.g. "can user act today?")
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const loading = isFetching;
|
|
76
|
+
const error = data?.error ?? false;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Important distinction**: `useEffect` that reads `localStorage` on mount (once, no fetch) should stay as `useEffect`. Only replace fetches.
|
|
80
|
+
|
|
81
|
+
**When ops are mixed** (e.g., initial check + later mutation both affect `loading`/`error`): split into separate states:
|
|
82
|
+
- `useQuery` owns check-loading/check-error
|
|
83
|
+
- `useState` owns action-loading/action-error (spin, submit, etc.)
|
|
84
|
+
- Derive the final values: `const loading = canSpinFetching || spinLoading`
|
|
85
|
+
|
|
86
|
+
**`QueryClientProvider` must be in the tree** — already set in `__root.tsx` via `QueryClient` with `staleTime: 30_000` default.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## 47. useEffect Data Fetches That Should NOT Be Replaced with useQuery
|
|
90
|
+
|
|
91
|
+
**Severity**: LOW — knowing what NOT to touch is as important as knowing what to replace.
|
|
92
|
+
|
|
93
|
+
After auditing all `useEffect` data fetches in a migrated storefront, three categories resist `useQuery`:
|
|
94
|
+
|
|
95
|
+
### ❌ DOM Side Effects Mixed With Fetch
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// SponsoredBannerHero.tsx — fetch result triggers createRoot + DOM events
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
invoke.site.loaders.sponsoredTopsort.sponsoredTopsort(params)
|
|
101
|
+
.then(response => {
|
|
102
|
+
createRoot(slot).render(<HeroContent banner={hero} />); // imperative DOM
|
|
103
|
+
root.dispatchEvent(new CustomEvent("sliderGoToIndex", ...));
|
|
104
|
+
});
|
|
105
|
+
return () => { createRoot(slot).render(null); }; // cleanup
|
|
106
|
+
}, [query, rootId, ...]);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The effect has cleanup logic and imperative DOM manipulation. `useQuery` only manages data — the DOM side effects still need `useEffect`. Refactoring would require separating fetch from DOM manipulation, which changes the architecture. Leave as-is.
|
|
110
|
+
|
|
111
|
+
### ❌ Paginated / Accumulating Data
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// MyOrdersListPage.tsx — appends pages, doesn't replace
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
getOrderingOrders(currentCursor); // pushes to existing array
|
|
117
|
+
}, [currentCursor]);
|
|
118
|
+
|
|
119
|
+
async function getOrderingOrders(cursor) {
|
|
120
|
+
setOrdersWithStatus(prev => [...prev, ...orders.data.orderingOrders]); // accumulate
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`useQuery` replaces data on each fetch. Accumulation requires `useInfiniteQuery`. Converting is a larger refactor and changes the UX (load-more vs infinite scroll semantics). Leave as-is unless doing a full pagination refactor.
|
|
125
|
+
|
|
126
|
+
### ❌ useReducer State (Complex Orchestration)
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
// OurStores.tsx — all state managed via dispatch
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
dispatch({ type: "SET_LOADING", payload: true });
|
|
132
|
+
invoke.site.actions.getStores()
|
|
133
|
+
.then(data => dispatch({ type: "SET_ALL_STORES", payload: data }))
|
|
134
|
+
.finally(() => dispatch({ type: "SET_LOADING", payload: false }));
|
|
135
|
+
}, []);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`useQuery` provides its own loading/data/error state. Integrating it with `useReducer` requires syncing query state → reducer state via another `useEffect`, which defeats the purpose. Options: (1) leave as-is, (2) migrate the whole component from `useReducer` to query + local `useState`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
**Rule of thumb**: Replace `useEffect` with `useQuery` only when the ONLY job of the effect is "fetch data → set state". If the effect also mutates the DOM, accumulates into existing state, or is tightly coupled to `useReducer`, leave it alone.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# React Signals & State (TanStack Store)
|
|
2
|
+
|
|
3
|
+
> Signal .value subscriptions, @tanstack/react-store, subscribe() API.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## 3. Signal .value in Render Doesn't Re-render
|
|
7
|
+
|
|
8
|
+
Reading `signal.value` inside React render doesn't create a subscription.
|
|
9
|
+
|
|
10
|
+
**Fix**: Use `useStore(signal.store)` from `@tanstack/react-store` for reactive reads.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## 19. @tanstack/store subscribe() Returns Object, Not Function
|
|
14
|
+
|
|
15
|
+
**Severity: CRITICAL** -- This causes cascading failures across the entire page.
|
|
16
|
+
|
|
17
|
+
`@tanstack/store@0.9.x`'s `Store.subscribe()` returns `{ unsubscribe: Function }`, NOT a plain function. React's `useSyncExternalStore` (and `useEffect` cleanup) expect the subscribe callback to return a bare unsubscribe function. Passing the object through causes:
|
|
18
|
+
|
|
19
|
+
1. "TypeError: destroy_ is not a function" (non-minified) / "TypeError: J is not a function" (minified)
|
|
20
|
+
2. Which cascades into React #419 (hydration failure)
|
|
21
|
+
3. Which cascades into React #130 (undefined component after hydration bailout)
|
|
22
|
+
4. Which makes the entire page non-interactive (0 interactive elements)
|
|
23
|
+
|
|
24
|
+
**Symptom**: Page SSR renders fine, but client shows "J is not a function" repeating hundreds of times. All interactive elements stop working.
|
|
25
|
+
|
|
26
|
+
**Fix**: Unwrap the return value in your `Signal.subscribe()` implementation:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
subscribe(fn) {
|
|
30
|
+
const sub = store.subscribe(() => fn());
|
|
31
|
+
return typeof sub === "function" ? sub : sub.unsubscribe;
|
|
32
|
+
},
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## 38. Signal Shim Doesn't Auto-Trigger React Re-renders
|
|
37
|
+
|
|
38
|
+
**Severity**: HIGH — drawers don't open, cart badge doesn't update, any signal-driven UI appears frozen
|
|
39
|
+
|
|
40
|
+
The Preact-to-React signal compat shim has a pub/sub pattern (`_listeners`), but reading `signal.value` in a React render function creates NO subscription. React components don't re-render when the signal changes.
|
|
41
|
+
|
|
42
|
+
**Symptom**: Setting `displayCart.value = true` doesn't open the cart drawer. Cart item count badge stays at 0 after adding items. Menu drawer toggle does nothing.
|
|
43
|
+
|
|
44
|
+
**Root cause**: In Preact, `@preact/signals` automatically tracks signal reads in render and re-renders. The shim just has get/set on `.value` with manual `_listeners` — React has no awareness of it.
|
|
45
|
+
|
|
46
|
+
**Fix (recommended)**: Use `useStore` from `@tanstack/react-store` for components that need reactive reads:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { useStore } from "@tanstack/react-store";
|
|
50
|
+
const { displayCart } = useUI();
|
|
51
|
+
const open = useStore(displayCart.store); // auto re-renders on change
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Fix (interim)**: For components not yet migrated to `useStore`, bridge with `useState` + `useEffect`:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const { displayCart } = useUI();
|
|
58
|
+
const [open, setOpen] = useState(displayCart.value);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const unsub = displayCart.subscribe(() => setOpen(displayCart.value));
|
|
61
|
+
return unsub;
|
|
62
|
+
}, []);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Fix (DaisyUI drawers)**: Since DaisyUI drawers are checkbox-driven, directly toggle the DOM checkbox as a pragmatic workaround:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const toggleDrawer = (id: string, open: boolean) => {
|
|
69
|
+
const checkbox = document.getElementById(id) as HTMLInputElement;
|
|
70
|
+
if (checkbox) checkbox.checked = open;
|
|
71
|
+
};
|
|
72
|
+
```
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: deco-tanstack-search
|
|
3
|
-
description: Implement and debug search functionality in Deco storefronts on TanStack Start. Covers the full search data flow from SearchBar to VTEX Intelligent Search API, including URL parameter propagation, CMS page resolution, filter toggling, pagination, and common pitfalls.
|
|
4
|
-
---
|
|
5
1
|
|
|
6
2
|
# Deco TanStack Search
|
|
7
3
|
|
|
8
4
|
Complete reference for implementing search in Deco storefronts running on TanStack Start / React / Cloudflare Workers.
|
|
9
5
|
|
|
10
|
-
## When to Use This
|
|
6
|
+
## When to Use This Reference
|
|
11
7
|
|
|
12
8
|
- Implementing or debugging search (`/s?q=...`) pages
|
|
13
9
|
- Fixing "search returns no results" or "search page shows 404"
|
|
@@ -390,14 +386,6 @@ TanStack Router's `search` is a plain `Record<string, string>` — it **cannot r
|
|
|
390
386
|
|
|
391
387
|
**Sort** (single key) can safely use `navigate({ search })` since there are no duplicate keys.
|
|
392
388
|
|
|
393
|
-
## Related Skills
|
|
394
|
-
|
|
395
|
-
- `deco-tanstack-storefront-patterns` — General runtime patterns for TanStack storefronts
|
|
396
|
-
- `deco-apps-vtex-porting` — Full guide for porting VTEX loaders to apps-start
|
|
397
|
-
- `deco-tanstack-navigation` — Navigation patterns including Link, prefetch, search params
|
|
398
|
-
- `deco-tanstack-hydration-fixes` — Fixing hydration and flash-of-white issues
|
|
399
|
-
- `deco-cms-route-config` — Deep dive into cmsRouteConfig and route helpers
|
|
400
|
-
|
|
401
389
|
## Files Reference
|
|
402
390
|
|
|
403
391
|
| File | Layer | Purpose |
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# @preact/signals -> TanStack Store Migration
|
|
2
|
+
|
|
3
|
+
Two distinct patterns need different handling.
|
|
4
|
+
|
|
5
|
+
## Pattern A: Component Hooks (useSignal, useComputed)
|
|
6
|
+
|
|
7
|
+
These are component-local state. Replace with React hooks directly.
|
|
8
|
+
|
|
9
|
+
### useSignal -> useState
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// OLD
|
|
13
|
+
import { useSignal } from "@preact/signals";
|
|
14
|
+
const loading = useSignal(false);
|
|
15
|
+
loading.value = true; // write
|
|
16
|
+
if (loading.value) { ... } // read
|
|
17
|
+
|
|
18
|
+
// NEW
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
setLoading(true); // write
|
|
22
|
+
if (loading) { ... } // read
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Setter naming convention: `set` + capitalized variable name.
|
|
26
|
+
|
|
27
|
+
### useComputed -> useMemo
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
// OLD
|
|
31
|
+
import { useComputed } from "@preact/signals";
|
|
32
|
+
const isValid = useComputed(() => name.value.length > 0);
|
|
33
|
+
return <div>{isValid.value}</div>;
|
|
34
|
+
|
|
35
|
+
// NEW
|
|
36
|
+
import { useMemo } from "react";
|
|
37
|
+
const isValid = useMemo(() => name.length > 0, [name]);
|
|
38
|
+
return <div>{isValid}</div>;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Automation Tips
|
|
42
|
+
|
|
43
|
+
- `useSignal`/`useComputed` changes are NOT safe for bulk sed (variable names, setter names, `.value` removal all differ per file)
|
|
44
|
+
- Process each file individually: read, identify variable names, transform
|
|
45
|
+
- Watch for: toggle patterns (`x.value = !x.value` -> `setX(prev => !prev)`), object state, conditional assignments
|
|
46
|
+
|
|
47
|
+
## Pattern B: Module-Level Signals (Global State)
|
|
48
|
+
|
|
49
|
+
These create shared state across components. Use `@tanstack/store`.
|
|
50
|
+
|
|
51
|
+
### Create ~/sdk/signal.ts
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { Store } from "@tanstack/store";
|
|
55
|
+
import { useSyncExternalStore, useMemo } from "react";
|
|
56
|
+
|
|
57
|
+
export interface Signal<T> {
|
|
58
|
+
readonly store: Store<T>;
|
|
59
|
+
value: T;
|
|
60
|
+
peek(): T;
|
|
61
|
+
subscribe(fn: () => void): () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function signal<T>(initialValue: T): Signal<T> {
|
|
65
|
+
const store = new Store<T>(initialValue);
|
|
66
|
+
return {
|
|
67
|
+
store,
|
|
68
|
+
get value() { return store.state; },
|
|
69
|
+
set value(v: T) { store.setState(() => v); },
|
|
70
|
+
peek() { return store.state; },
|
|
71
|
+
subscribe(fn) {
|
|
72
|
+
// CRITICAL: @tanstack/store@0.9.x returns { unsubscribe: Function },
|
|
73
|
+
// NOT a plain function. React's useSyncExternalStore and useEffect
|
|
74
|
+
// cleanup both expect a bare function. Passing the object causes
|
|
75
|
+
// "TypeError: destroy_ is not a function" at runtime.
|
|
76
|
+
const sub = store.subscribe(() => fn());
|
|
77
|
+
return typeof sub === "function" ? sub : sub.unsubscribe;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useSignal<T>(initialValue: T): Signal<T> {
|
|
83
|
+
const sig = useMemo(() => signal(initialValue), []);
|
|
84
|
+
useSyncExternalStore(
|
|
85
|
+
(cb) => sig.subscribe(cb),
|
|
86
|
+
() => sig.value,
|
|
87
|
+
() => sig.value,
|
|
88
|
+
);
|
|
89
|
+
return sig;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useComputed<T>(fn: () => T): Signal<T> {
|
|
93
|
+
const sig = useMemo(() => signal(fn()), []);
|
|
94
|
+
return sig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function computed<T>(fn: () => T): Signal<T> {
|
|
98
|
+
return signal(fn());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function effect(fn: () => void | (() => void)): () => void {
|
|
102
|
+
const cleanup = fn();
|
|
103
|
+
return typeof cleanup === "function" ? cleanup : () => {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function batch(fn: () => void): void {
|
|
107
|
+
fn();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function useSignalEffect(fn: () => void | (() => void)): void {
|
|
111
|
+
fn();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type { Signal as ReadonlySignal };
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> **WARNING**: The `subscribe()` unwrapping is the single most critical line in
|
|
118
|
+
> this file. Without it, every component using `useSignal` will crash with
|
|
119
|
+
> "TypeError: J is not a function" (minified) or "TypeError: destroy_ is not a
|
|
120
|
+
> function" (non-minified), which then cascades into React #419 hydration
|
|
121
|
+
> failures and #130 undefined component errors across the entire page.
|
|
122
|
+
|
|
123
|
+
### Replace Imports
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Safe for bulk sed
|
|
127
|
+
sed -i '' 's|from "@preact/signals"|from "~/sdk/signal"|g'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### React Subscriptions
|
|
131
|
+
|
|
132
|
+
The signal shim's `.value` getter/setter does NOT create automatic React subscriptions. Reading `signal.value` during render won't re-render when the signal changes. This is the #1 source of "it works in Preact but not React" bugs.
|
|
133
|
+
|
|
134
|
+
Replace manual useEffect+subscribe boilerplate with `useStore`:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// OLD (manual subscription -- works but verbose)
|
|
138
|
+
const { displayCart } = useUI();
|
|
139
|
+
const [open, setOpen] = useState(false);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setOpen(displayCart.value);
|
|
142
|
+
return displayCart.subscribe(() => setOpen(displayCart.value));
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
// NEW (useStore from @tanstack/react-store -- recommended)
|
|
146
|
+
import { useStore } from "@tanstack/react-store";
|
|
147
|
+
const { displayCart } = useUI();
|
|
148
|
+
const open = useStore(displayCart.store);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Write-only consumers (event handlers) don't need `useStore`:
|
|
152
|
+
```typescript
|
|
153
|
+
// This still works -- .value setter backed by TanStack Store
|
|
154
|
+
onClick={() => { displayCart.value = true; }}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### DaisyUI Drawer Workaround
|
|
158
|
+
|
|
159
|
+
DaisyUI drawers use a hidden checkbox to toggle visibility. Since signal changes don't trigger React re-renders, the checkbox `checked` attribute never updates. Pragmatic workaround: directly toggle the DOM checkbox alongside the signal:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// After setting the signal, also toggle the drawer checkbox
|
|
163
|
+
displayCart.value = true;
|
|
164
|
+
const checkbox = document.getElementById("cart-drawer") as HTMLInputElement;
|
|
165
|
+
if (checkbox) checkbox.checked = true;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This is an interim pattern until all signal consumers use `useStore`. The `useStore` approach is the proper fix because it makes the Drawer component re-render, which updates the checkbox `checked` prop through React.
|
|
169
|
+
|
|
170
|
+
### Global State Hook Pattern (useCart, useUI)
|
|
171
|
+
|
|
172
|
+
Hooks that manage global state (cart, UI drawers) need module-level singleton state with a subscription mechanism. The pattern:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
let _state: CartData | null = null;
|
|
176
|
+
const _listeners = new Set<() => void>();
|
|
177
|
+
|
|
178
|
+
function notify() { _listeners.forEach((fn) => fn()); }
|
|
179
|
+
|
|
180
|
+
export function useCart() {
|
|
181
|
+
const [, forceUpdate] = useState(0);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const listener = () => forceUpdate((n) => n + 1);
|
|
185
|
+
_listeners.add(listener);
|
|
186
|
+
return () => { _listeners.delete(listener); };
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
cart: {
|
|
191
|
+
get value() { return _state; },
|
|
192
|
+
set value(v) { _state = v; notify(); },
|
|
193
|
+
},
|
|
194
|
+
// ... methods that update _state and call notify()
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Every component calling `useCart()` subscribes to changes, and state updates trigger re-renders across all subscribers. This replaces the Preact signals global reactivity.
|
|
200
|
+
|
|
201
|
+
## Signal Type References
|
|
202
|
+
|
|
203
|
+
If components use `Signal<T>` as a prop type:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// OLD
|
|
207
|
+
import { Signal } from "@preact/signals";
|
|
208
|
+
interface Props { quantity: Signal<number>; }
|
|
209
|
+
|
|
210
|
+
// NEW
|
|
211
|
+
import type { ReactiveSignal } from "~/sdk/signal";
|
|
212
|
+
interface Props { quantity: ReactiveSignal<number>; }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Verification
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
grep -r '@preact/signals' src/ --include='*.ts' --include='*.tsx'
|
|
219
|
+
# Should return ZERO matches
|
|
220
|
+
```
|