Skip to content

Commit 627e035

Browse files
JonasJesus42claude
andcommitted
docs: add reusable Deco skills documentation
Add five battle-tested skills for building fast, maintainable storefronts on TanStack Start with React and VTEX: - deco-minicart-configuravel: API-frugal on-demand minicart with CMS config - deco-add-to-cart-slim: Slim add-to-cart response (~0.3KB vs 97KB) - deco-signal-reactivity-react: Signal reactivity gotcha in Preact→React migration - deco-micro-skeletons: Fine-grained loading states without layout shift - deco-nav-prefetch: Hybrid HTML + SPA prefetch strategy All reference implementations tested at montecarlo-tanstack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 53410fe commit 627e035

6 files changed

Lines changed: 564 additions & 0 deletions

skills/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Deco Skills
2+
3+
A collection of reusable, battle-tested patterns and best practices for building fast, maintainable storefronts with Deco on TanStack Start, React, and VTEX.
4+
5+
## Skills
6+
7+
### [Configurable On-Demand Minicart](./deco-minicart-configuravel.md)
8+
Build an API-frugal, CMS-configurable VTEX minicart with React Query. Zero `getOrCreateCart` on page load, lazy orderForm creation, canonical Minicart shape, micro-skeletons, and toast-vs-drawer toggle.
9+
10+
**Reference:** `montecarlo-tanstack`
11+
12+
### [Slim Add-to-Cart (Fetch Inteligente)](./deco-add-to-cart-slim.md)
13+
Optimize add-to-cart bandwidth from 97 KB → 0.3 KB by returning only essential data on add, deferring full cart hydration to drawer-open intent.
14+
15+
**Benefit:** ~99.7% bandwidth reduction, no duplicate cart fetches.
16+
17+
### [Signal Reactivity in React (Preact→React Migration Gotcha)](./deco-signal-reactivity-react.md)
18+
Critical migration gotcha: reading `signal.value` in render doesn't re-render in React. Use `useSignalValue` hook instead.
19+
20+
**Symptom:** Drawer/modal doesn't open on click, but analytics logs fire.
21+
22+
### [Micro-Skeletons Without Layout Shift](./deco-micro-skeletons.md)
23+
Implement fine-grained loading states per line/section using pulse-in-place (not fixed boxes) to preserve exact dimensions and avoid CLS violations.
24+
25+
**Pattern:** Disable the real widget, don't hide it.
26+
27+
### [Navigation Prefetch (HTML + SPA)](./deco-nav-prefetch.md)
28+
Combine HTML prefetch-on-hover (nav links, using bagaggio/instant.page) with SPA Link wrapper prefetch (product cards) for perceived performance gains.
29+
30+
**Benefit:** Category pages prefetch on nav hover; PDPs prefetch on card hover.
31+
32+
## Using These Skills
33+
34+
1. Pick a skill relevant to your use case.
35+
2. Read the full skill document for context, gotchas, and trade-offs.
36+
3. Copy the reference implementation patterns into your project.
37+
4. Follow the verification checklist to validate the integration.
38+
39+
## Reference Implementations
40+
41+
All skills are tested in production at:
42+
- **montecarlo-tanstack** — TanStack Start + React + VTEX, Deco framework
43+
44+
## Contributing
45+
46+
Found a gotcha not documented here? Have a better pattern? Open a PR or discussion — these skills are living docs.

skills/deco-add-to-cart-slim.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
title: Slim Add-to-Cart (Fetch Inteligente)
3+
description: Optimize add-to-cart bandwidth by returning only essential data (~0.3KB) on add, deferring full cart hydration to drawer-open intent.
4+
tags: [performance, add-to-cart, fetch, optimization]
5+
---
6+
7+
# Slim Add-to-Cart (Fetch Inteligente)
8+
9+
## Problem
10+
Traditional add-to-cart returns the full VTEX OrderForm (~97 KB) on every add, even though the browser only needs: `orderFormId`, item count, and total. This causes bandwidth waste and network latency spikes.
11+
12+
## Solution
13+
Create a slim server function that runs the add server-side but returns only `{ orderFormId, itemCount, totalQuantity, value }` (~0.3 KB). The full OrderForm is fetched on-demand only when the drawer opens.
14+
15+
## Implementation
16+
17+
### 1. Server Function (Slim Add)
18+
```ts
19+
// src/server/invoke.ts
20+
import { addItemsToCart } from "@decocms/apps-vtex/actions/checkout";
21+
22+
export interface SlimCartResult {
23+
orderFormId: string;
24+
itemCount: number;
25+
totalQuantity: number;
26+
value: number;
27+
}
28+
29+
const _addItemsToCartSlim = createServerFn({ method: "POST" })
30+
.inputValidator((data: {
31+
orderFormId: string;
32+
orderItems: Array<{ id: string; seller: string; quantity: number }>;
33+
}) => data)
34+
.handler(async ({ data }): Promise<SlimCartResult> => {
35+
// VTEX returns full OrderForm; we extract only what the browser needs.
36+
const of = await addItemsToCart(data);
37+
const items = of?.items ?? [];
38+
return {
39+
orderFormId: of?.orderFormId ?? data.orderFormId,
40+
itemCount: items.length,
41+
totalQuantity: items.reduce((s, it) => s + (it?.quantity ?? 0), 0),
42+
value: of?.value ?? 0,
43+
};
44+
});
45+
```
46+
47+
### 2. Query Hook (On-Demand Gate)
48+
```ts
49+
// src/sdk/cart/useCartQuery.ts
50+
const addItemsMutation = useMutation({
51+
mutationFn: async (params: { orderItems: Array<{ id: string; seller: string; quantity: number }> }) => {
52+
markCartMutated();
53+
const orderFormId = await ensureOrderForm();
54+
return invoke.vtex.actions.addItemsToCartSlim({ data: { orderFormId, orderItems: params.orderItems } });
55+
},
56+
onSuccess: (slim) => {
57+
// Slim result: only write cookie + badge, don't hydrate full cart here.
58+
if (slim?.orderFormId) writeOrderFormCookie(slim.orderFormId);
59+
writeCartCount(slim?.itemCount ?? 0);
60+
// Invalidate so the next drawer-open (when enabled becomes true) refetches authoritative data.
61+
queryClient.invalidateQueries({ queryKey: cartKeys.all });
62+
},
63+
});
64+
```
65+
66+
### 3. Fetch Gate (Intent + Cookie)
67+
```ts
68+
// src/sdk/cart/queries.ts
69+
export function shouldFetchCart(displayCartIntent: boolean): boolean {
70+
return displayCartIntent && (Boolean(readOrderFormCookie()) || _mutationRanThisSession);
71+
}
72+
```
73+
74+
## Benefits
75+
- **Add bandwidth:** 97 KB → 0.3 KB (~99.7% reduction)
76+
- **No duplicate getOrCreateCart:** The old gate's `mutationRan` term caused the full cart to fetch immediately after add. New gate defers to drawer-open intent.
77+
- **Badge works without full hydration:** `cart_item_count` cookie keeps header badge updated.
78+
79+
## Trade-offs
80+
- Full OrderForm is fetched later (on drawer open), not immediately. This is intentional — the add user usually doesn't open the drawer immediately.
81+
- A user who adds then immediately opens the drawer will see a brief skeleton while fetching. This is acceptable UX.
82+
83+
## Verification
84+
- **Network:** Add-to-cart request is ~0.3 KB; opening drawer triggers the full orderForm fetch.
85+
- **Correctness:** Quantity changes and subsequent adds work correctly with stale data handling.

skills/deco-micro-skeletons.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
title: Micro-Skeletons Without Layout Shift
3+
description: Implement fine-grained loading states per line and section without causing visual collapse or layout thrashing.
4+
tags: [ux, skeletons, performance, css, loading-states]
5+
---
6+
7+
# Micro-Skeletons Without Layout Shift
8+
9+
## Problem
10+
Traditional skeleton screens that swap out the real content with fixed-size placeholder boxes cause **layout shift** (CLS violation) and visual jarring. Example: a cart line's price block is 2 lines when discounted (strikethrough + final price), but the skeleton is 1 line — the row collapses during fetch.
11+
12+
## Solution
13+
**Pulse the real content in place** instead of swapping for boxes. Use `animate-pulse` + `opacity` on the actual DOM while keeping exact dimensions and multi-line structure preserved.
14+
15+
## Patterns
16+
17+
### Per-Line Quantity Skeleton
18+
**❌ Old approach (layout shift):**
19+
```tsx
20+
{isPending ? (
21+
<div className="skeleton h-9 w-24" />
22+
) : (
23+
<QuantitySelector ... />
24+
)}
25+
```
26+
27+
**✅ New approach (no shift):**
28+
```tsx
29+
<QuantitySelector
30+
disabled={loading || isGift || isPending} // stay disabled, not hidden
31+
quantity={quantity}
32+
...
33+
/>
34+
```
35+
The selector is always visible and always takes up the same space. While pending, it's just disabled (the user can't interact, but the visual layout is stable).
36+
37+
### Price Block with Pulse
38+
**❌ Old approach (layout shift):**
39+
```tsx
40+
{isPending ? (
41+
<div className="skeleton h-5 w-16" />
42+
) : (
43+
<>
44+
{sale != list && (
45+
<span className="text-[#AAA89C] text-xs line-through">
46+
{formatPrice(list, currency, locale)}
47+
</span>
48+
)}
49+
<span className="text-base font-semibold">
50+
{formatPrice(sale, currency, locale)}
51+
</span>
52+
</>
53+
)}
54+
```
55+
56+
**✅ New approach (no shift):**
57+
```tsx
58+
<div className={`flex flex-col justify-end items-end ${isPending ? 'animate-pulse opacity-40' : ''}`}>
59+
{sale != list && (
60+
<span className="text-[#AAA89C] text-xs line-through">
61+
{formatPrice(list, currency, locale)}
62+
</span>
63+
)}
64+
<span className="text-base font-semibold">
65+
{formatPrice(sale, currency, locale)}
66+
</span>
67+
</div>
68+
```
69+
The block stays in the DOM with its 2 lines intact. When pending, it pulses (opacity drop + animation). Exact dimensions are preserved.
70+
71+
### Cart Footer Total
72+
Same pattern:
73+
```tsx
74+
<span
75+
className={`text-lg font-semibold transition-opacity ${isMutating ? 'animate-pulse opacity-40' : ''}`}
76+
>
77+
{formatPrice(total, currency, locale)}
78+
</span>
79+
```
80+
81+
## Benefits
82+
- **Zero layout shift:** No CLS violation, no row collapse.
83+
- **Visual feedback:** User still sees loading state (pulse + opacity).
84+
- **No interaction layer:** No need to disable the real widget — it's just dimmed.
85+
- **Semantic:** The actual content stays in the DOM; CSS handles the visual feedback.
86+
87+
## Trade-offs
88+
- The pulse effect is subtle — ensure it's visible enough (opacity 0.4–0.5 works).
89+
- Not suitable for skeleton screens that show "shape hints" (e.g., placeholder text lines) — those need fixed boxes. Use micro-skeletons only for fields that recalculate, not for structure that changes.
90+
91+
## CSS Classes Used
92+
- `animate-pulse` — DaisyUI / Tailwind built-in (oscillates opacity 0.5 ↔ 1).
93+
- `opacity-40` — dims the content while pulsing.
94+
- `transition-opacity` — smooths the opacity change.
95+
96+
## Verification
97+
- **Visual:** Open cart, change quantity. The line doesn't collapse; price pulses in place.
98+
- **CLS:** Lighthouse score doesn't drop due to layout shift.
99+
- **Interaction:** Quantity selector stays clickable (disabled state prevents actual mutation, but the DOM is stable).
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
---
2+
title: Configurable On-Demand Minicart (TanStack / React Query)
3+
description: Build an API-frugal, CMS-configurable VTEX minicart for Deco storefronts on TanStack Start with React Query. No getOrCreateCart on page load, lazy orderForm creation, canonical Minicart shape, micro-skeletons, and toast-vs-drawer toggle.
4+
reference: montecarlo-tanstack
5+
tags: [minicart, react-query, vtex, performance, cms, ux]
6+
---
7+
8+
# Configurable On-Demand Minicart (TanStack / React Query / VTEX)
9+
10+
Turn a VTEX minicart into an API-frugal, CMS-configurable, replicable component on `@decocms/start` (TanStack Start / React / Cloudflare) with `@decocms/apps-vtex@7.20+`.
11+
12+
**Reference implementation:** Monte Carlo (`montecarlo-tanstack`).
13+
14+
## Goals this Delivers
15+
16+
1. **Zero `getOrCreateCart` calls on page load / F5.** The cart is a react-query query gated so a returning shopper reloading a page triggers ZERO orderForm calls. Header badge renders from a lightweight `cart_item_count` cookie instead.
17+
2. **Empty cart without API calls.** A cookieless visitor opening the drawer sees an empty state with zero API calls; the orderForm is created only on the first add-to-cart.
18+
3. **Canonical `Minicart` shape.** Adopt the platform-agnostic `Minicart` type from `@decocms/apps-vtex/utils/minicart` so totals, currency, locale, and free-shipping math come from one boundary conversion, not ad-hoc field digging.
19+
4. **Micro-skeletons without layout shift.** Per-line quantity/price skeletons + footer total skeleton via pulse-in-place (not fixed boxes), preserving exact dimensions and preventing row collapse.
20+
5. **CMS-editable config + composable shelf.** A loader-based config with live Preview (Farm pattern) + a slot for dropping product shelves inside the cart via `SectionRenderer`.
21+
6. **Toast-vs-drawer toggle.** A "notification (toast)" switch: ON (default) = toast on add + drawer stays closed; OFF = drawer auto-opens. Driven by CMS config.
22+
23+
## Architecture
24+
25+
### On-Demand React Query Cart
26+
27+
`src/sdk/cart/` holds the core SDK:
28+
29+
- **`queries.ts`**`cartKeys`, orderForm cookie helpers, the `cart_item_count` badge cookie, and `shouldFetchCart(displayCartIntent)` (the fetch gate).
30+
- **`useCartQuery.ts`**`useCart()`: react-query query + optimistic mutations. Exposes legacy signal-shaped surface (`cart.value`, `loading.value`) AND the canonical `minicart` via `useMemo`.
31+
- **`config.ts`** — Module-level `cartConfig` signal + `setCartConfig` (toast, free-shipping threshold, coupon toggle, checkout href). Read by add-button and toast island without prop drilling.
32+
33+
**The fetch gate** is the heart of goal #1/#2:
34+
```ts
35+
// shouldFetchCart(intent) — fetch ONLY when the shopper intends to open AND a cart exists
36+
// (a persisted orderFormId cookie OR a mutation ran this session).
37+
return displayCartIntent && (Boolean(readOrderFormCookie()) || _mutationRanThisSession);
38+
```
39+
40+
Badge count is served from the `cart_item_count` cookie (written on every commit + optimistic patch), read client-only in a `useEffect` to avoid SSR hydration mismatch.
41+
42+
### Invoke vs Direct Fetch (Critical Reconciliation)
43+
44+
`@decocms/apps-vtex@7.20`'s stock `hooks/useCart` does browser `fetch("/api/checkout/pub/orderForm")` — only works on a VTEX-proxied domain. Deco storefronts on custom domains do NOT proxy `/api/checkout`; they use the `invoke` server-function proxy.
45+
46+
**Do NOT adopt the stock hook as-is.** Instead **graft** the pure/portable parts:
47+
48+
- The canonical type `Minicart` (`@decocms/apps-commerce/types`)
49+
- The transform `vtexOrderFormToMinicart` (`@decocms/apps-vtex/utils/minicart`)
50+
- The `loaders/minicart` "empty shell when no cookie" pattern
51+
52+
onto your existing `invoke`-based, on-demand local cart. Compute `minicart` with `useMemo`:
53+
54+
```ts
55+
const minicart = useMemo(() => data ? vtexOrderFormToMinicart(data, {
56+
freeShippingTarget: config.freeShippingTarget,
57+
checkoutHref: config.checkoutHref,
58+
enableCoupon: config.enableCoupon,
59+
}) : null, [data, config.freeShippingTarget, config.checkoutHref, config.enableCoupon]);
60+
```
61+
62+
**Alternative (out of scope):** Reverse-proxy `/api/checkout` → VTEX at the edge to use the stock hook directly. Document, don't implement.
63+
64+
### CMS Config as Loader with Preview (Not a Section)
65+
66+
The minicart drawer is a **layout-shell overlay** (always mounted in `Header/Drawers`, opened by a global `displayCart` signal). It is NOT a page section — don't try to make it one.
67+
68+
- **`src/loaders/minicart.tsx`** — Identity loader returning `MinicartConfig` (rich JSDoc: `freeShippingTarget`, `enableCoupon`, `checkoutHref`, `variant`, `showAddToCartToast`, `addedToast`, `emptyState`, `shelfSections?: Section[]`). Also `export const Preview = (config) => JSX` — a self-contained HTML preview of the configured minicart open and populated (Farm pattern, e.g. `deco-sites/farmrio/loaders/Layouts/Tags.tsx`). Keep Preview dependency-free (no runtime hooks).
69+
- **Header receives flat config object** (`cart.config?: MinicartConfig`), passes it through to `Drawers → Cart`. No `SectionRenderer` wrapping the drawer.
70+
- **Composable shelf slot**`common/Cart.tsx` renders `shelfSections` via `SectionRenderer` so the admin can drop a product shelf (Granado style) inside the cart. Scope is localized.
71+
72+
## File Map (Copy/Adapt per Site)
73+
74+
| File | Role |
75+
|---|---|
76+
| `src/sdk/cart/queries.ts` | Fetch gate, orderForm + `cart_item_count` cookies |
77+
| `src/sdk/cart/useCartQuery.ts` | `useCart()`, react-query, optimistic mutations, `minicart` graft |
78+
| `src/sdk/cart/config.ts` | `cartConfig` signal + `setCartConfig` |
79+
| `src/loaders/minicart.tsx` | `MinicartConfig` + identity loader + `Preview` |
80+
| `src/components/miniCart/common/Cart.tsx` | Drawer body, empty state, shelf slot, micro-skeletons |
81+
| `src/components/miniCart/vtex/Cart.tsx` | Adapter: `minicart.storefront` → BaseCart props |
82+
| `src/components/miniCart/AddedToCartToast.tsx` | Toast island (photo, price, type, message) |
83+
| `src/components/Header/Drawers.tsx` | Hosts drawer + toast; **subscribes display signals via `useSignalValue`** |
84+
| `src/components/Header/Header.tsx` | Publishes config via `setCartConfig`, passes to Drawers |
85+
| `src/components/Header/Buttons/Cart/{common,vtex}.tsx` | Badge from cookie + hover prefetch |
86+
| `src/components/Product/AddToCartButton/{common,vtex}.tsx` | Optimistic toast/drawer + real product `image` prop |
87+
88+
## Gotchas (These Cost the Most Time)
89+
90+
### 1. Signal Reactivity (Preact → React)
91+
Reading `signal.value` directly in render does NOT re-render a React component (unlike @preact/signals).
92+
93+
**Symptom:** Drawer "does not open" — the click fires (analytics logs) and sets `displayCart.value=true`, but nothing re-renders.
94+
95+
**FIX:** Subscribe with `useSignalValue(sig)` (useSyncExternalStore) for every render-time read of a module signal (`displayCart`, `cartConfig`, `cartToast`). Writes in handlers stay `sig.value = x`.
96+
97+
### 2. Optimistic Toast Timing
98+
Fire the toast / open the drawer BEFORE `await onAddItem()`, not after — otherwise feedback is delayed by the server round-trip and never shows if the mutation rejects. The mutation carries its own optimistic patch + rollback.
99+
100+
### 3. Toast Photo
101+
`mapProductToAnalyticsItem` gives `item_url` (product page URL), NOT an image. Thread the real image (`product.image?.[0]?.url`) into the add button and use it for the toast.
102+
103+
### 4. Directory Casing (macOS vs Linux CI)
104+
The git index may hold `minicart`/`ui` lowercase while the macOS working tree shows `miniCart`/`UI`. Import using git-indexed casing (`~/components/minicart/...`) or Linux CI + `tsc` (TS1149/TS1261) breaks. Check with `git ls-files | grep -i <path>`.
105+
106+
### 5. CMS Codegen Migration Blocker (7.20+ Bump)
107+
After bumping to `@decocms/*@7.20+`, the generators (`@decocms/blocks-cli`) write to `.deco/` in a NEW format, but a repo migrated earlier still consumes OLD-format files in `src/server/{cms,admin}/` (from `@decocms/start`). Running the generators does NOT update what the app reads.
108+
109+
**Consequence:** New CMS props (`cart.config`, toast toggle) are code-ready but NOT admin-editable until the repo does the codegen migration (switch `setup.ts` importers to `.deco/` OR regenerate all artifacts consistently).
110+
111+
**Mitigation:** Design runtime defaults so the site behaves correctly WITHOUT any CMS config (e.g. `autoOpenOnAdd: false` → toast active, `freeShippingTarget: 500`). Then editability lands for free once the migration runs.
112+
113+
## Verification Checklist
114+
115+
- **Types:** `npx tsc --noEmit` — compare error COUNT to a pre-change baseline. Zero NEW errors is the bar.
116+
- **Browser (with dev server):**
117+
- F5 with an existing cart cookie → **no** `orderForm` POST.
118+
- Drawer opens cookieless → empty state, **zero** API calls.
119+
- Add-to-cart → orderForm created once; with toast ON → toast (photo/price/type), drawer stays closed; with toast OFF → drawer opens.
120+
- Change quantity → skeleton only on that line + total, rest stable (no layout shift).
121+
- Hover on icon with cookie → prefetch cart before click.
122+
- **SSR check:** `curl -s localhost:PORT/ | grep data-qa-minicart` returns nothing (drawer body must not be in SSR HTML).
123+
- **Admin (post-codegen-migration):** Edit Minicart config (free-shipping threshold, coupon toggle, toast label), see shelf composability work, Preview reflects changes.

0 commit comments

Comments
 (0)