@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.
Files changed (34) hide show
  1. package/.agents/skills/deco-migrate-script/SKILL.md +434 -0
  2. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +382 -0
  3. package/.agents/skills/deco-to-tanstack-migration/references/admin-cms.md +154 -0
  4. package/{.cursor/skills/deco-async-rendering-site-guide/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/async-rendering.md} +296 -31
  5. package/.agents/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
  6. package/.agents/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
  7. package/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +156 -0
  8. package/.agents/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
  9. package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +13 -0
  10. package/{.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/hydration-fixes.md} +139 -4
  11. package/.agents/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
  12. package/{.cursor/skills/deco-islands-migration/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/islands.md} +0 -14
  13. package/.agents/skills/deco-to-tanstack-migration/references/jsx-migration.md +80 -0
  14. package/.agents/skills/deco-to-tanstack-migration/references/matchers.md +1064 -0
  15. package/{.cursor/skills/deco-tanstack-navigation/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/navigation.md} +1 -16
  16. package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
  17. package/.agents/skills/deco-to-tanstack-migration/references/react-hooks-patterns.md +142 -0
  18. package/.agents/skills/deco-to-tanstack-migration/references/react-signals-state.md +72 -0
  19. package/{.cursor/skills/deco-tanstack-search/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/search.md} +1 -13
  20. package/.agents/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
  21. package/{.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/storefront-patterns.md} +1 -137
  22. package/.agents/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
  23. package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +165 -0
  24. package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +209 -0
  25. package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
  26. package/.agents/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
  27. package/.agents/skills/deco-to-tanstack-migration/templates/router.md +96 -0
  28. package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
  29. package/.agents/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
  30. package/.agents/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
  31. package/README.md +45 -0
  32. package/package.json +1 -1
  33. package/src/routes/cmsRoute.ts +7 -4
  34. 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 Skill
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 Skill
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
+ ```