Skip to content

Commit 4ea7cae

Browse files
JonasJesus42claude
andcommitted
perf(router): expose preloadStaleTime + add pdp-fast-navigation skill
createDecoRouter only exposes defaultPreload (no defaultPreloadStaleTime, GcTime, or Delay), so prefetch results may be considered stale by the time the user clicks — causing a wasted second fetch. Replace createDecoRouter with createTanStackRouter direct, re-importing the Deco search parsers (decoParseSearch/decoStringifySearch) so URL behavior is unchanged. Add: - defaultPreloadStaleTime: 60_000 - defaultPreloadGcTime: 5 * 60_000 - QueryClient staleTime: 60_000 (was 30_000) Result: hover prefetch is reused on click within 60s — typical product card → PDP navigation becomes instant. Also add .cursor/skills/deco-pdp-fast-navigation/SKILL.md documenting the full pattern (4 levers + anti-CLS) discovered while optimizing baggagio-tanstack. Storefronts cloned from this template inherit both the router config and the skill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 09655bf commit 4ea7cae

2 files changed

Lines changed: 410 additions & 4 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
---
2+
name: deco-pdp-fast-navigation
3+
description: Make PDP navigation feel instant in Deco TanStack Start storefronts. Combines TanStack Router intent prefetch with cache reuse, eager sections for atomic page swap, reserved-height LoadingFallback to eliminate CLS, and LRU caching for heavy loaders (360 thumbnails, Vimeo oEmbed). Use when product card click → PDP open feels slow, when there is visible flicker/skeleton flash on navigation, or when PDP loader has many parallel fetches and click feels several seconds slow.
4+
---
5+
6+
# PDP Fast Navigation
7+
8+
Patterns for making product card → PDP navigation feel instant in Deco storefronts on TanStack Start. Discovered while optimizing `baggagio-tanstack` where each click had a multi-second delay caused by 7 parallel server fetches in the PDP loader and `<a href>` cards (no prefetch).
9+
10+
## When to Use This Skill
11+
12+
- Click on a product card → PDP open feels noticeably slow (>1s perceived)
13+
- PDP shows a skeleton/empty shell that flashes before content arrives
14+
- Footer "jumps" up to the header then down again when PDP content loads (CLS)
15+
- Product cards use `<a href>` instead of TanStack's `<Link>`
16+
- PDP loader has `Promise.all` with many parallel API calls
17+
- HAR or Network tab shows repeated HEAD requests for thumbnail format detection
18+
- `createDecoRouter` is used and there is no way to tune preload caching
19+
20+
---
21+
22+
## The Four Levers
23+
24+
These four optimizations compound — applying all of them is what makes PDP feel instant.
25+
26+
| Lever | What it solves | Effort |
27+
|-------|----------------|--------|
28+
| 1. `<Link preload="intent">` on cards | No prefetch at all | Drop-in replace `<a href>` |
29+
| 2. `eager: true` on PDP sections | Avoids "empty shell flash" on navigation | Add export + regen sections |
30+
| 3. `createTanStackRouter` direct + `defaultPreloadStaleTime` | Preload result is reused on click, not refetched | Replace `createDecoRouter` |
31+
| 4. LRU cache on heavy loaders (360, Vimeo) | Repeated fetches across navigations | Module-scope `Map` |
32+
33+
If you only apply 1 and 2, navigation may still feel slow because the preload result is not reused on click. Levers 3 and 4 close that gap.
34+
35+
---
36+
37+
## Lever 1 — Replace `<a href>` with `<Link preload="intent">` in Product Cards
38+
39+
The default behavior of TanStack Router's `<Link preload="intent">` is to prefetch the target route on hover (desktop) and on touchstart (mobile) after a ~50ms debounce. This warms the route loader before the user even clicks.
40+
41+
### Before
42+
43+
```tsx
44+
import { relative } from "@decocms/apps/commerce/sdk/url";
45+
46+
function ProductCard({ product }) {
47+
const relativeUrl = relative(product.url);
48+
return (
49+
<a href={relativeUrl} aria-label="view product" className="...">
50+
<Image src={...} />
51+
</a>
52+
);
53+
}
54+
```
55+
56+
### After
57+
58+
```tsx
59+
import { Link } from "@tanstack/react-router";
60+
import { relative } from "@decocms/apps/commerce/sdk/url";
61+
62+
function ProductCard({ product }) {
63+
const relativeUrl = relative(product.url);
64+
return (
65+
<Link to={relativeUrl} preload="intent" aria-label="view product" className="...">
66+
<Image src={...} />
67+
</Link>
68+
);
69+
}
70+
```
71+
72+
### Notes
73+
74+
- `<Link to={string}>` accepts a relative URL directly — TanStack handles the splat (`/$`) param parsing internally. No need to decompose into `{ to: "/$", params: { _splat } }`.
75+
- `<Link>` renders an `<a href>` in the DOM, so SSR/SEO/no-JS still work.
76+
- Apply to **every variant of the card**: main grid card, mini card in search dropdown, card on PDP shelves, etc. A single missed card means missed prefetch.
77+
78+
### How to Find All Cards
79+
80+
```bash
81+
rg '<a href=\{relativeUrl\}|<a href=\{relative\(' src/components/product/ -l
82+
```
83+
84+
---
85+
86+
## Lever 2 — Mark Critical PDP Sections as `eager`
87+
88+
By default, Deco sections that export `LoadingFallback` are treated as **deferred**: when the user navigates to the PDP, the framework renders the new URL immediately, shows the `LoadingFallback`, and replaces it with the real content when the loader resolves. This causes two visible "steps":
89+
90+
1. URL changes → user sees an empty shell (the `LoadingFallback`)
91+
2. Loader resolves → content fills in, pushing the page layout around
92+
93+
Marking the section as `eager: true` changes the behavior to **atomic**:
94+
95+
1. URL changes → the **current page stays visible** until the new page's loader fully resolves
96+
2. New page swaps in already-complete
97+
98+
This eliminates the "two-step" feel. Combined with `<Link preload="intent">` and `defaultPreloadStaleTime`, the swap can happen instantly because the data was already fetched on hover.
99+
100+
### How
101+
102+
Add this export at the bottom of each critical PDP section file:
103+
104+
```tsx
105+
// src/sections/Product/ProductDetails.tsx (or ContainerPDP.tsx, etc.)
106+
107+
export function LoadingFallback() {
108+
// see Lever 5 below — reserve height even though eager hides it most of the time
109+
return <div className="min-h-[1100px] lg:min-h-[716px] w-full" />;
110+
}
111+
112+
export const eager = true;
113+
```
114+
115+
Then regenerate the sections registry:
116+
117+
```bash
118+
bun run generate:sections
119+
```
120+
121+
The output should confirm the `eager` flag:
122+
123+
```
124+
"site/sections/Product/ProductDetails.tsx": { eager: true, hasLoadingFallback: true },
125+
```
126+
127+
### When NOT to Mark as Eager
128+
129+
- **Below-the-fold sections** like related products, reviews, cross-sell shelves — these can stay deferred. Marking them eager makes the user wait for non-critical data before seeing the PDP.
130+
- **Sections with truly optional data** — anything that the user does not see in the first viewport.
131+
132+
For the Baggaggio PDP, the eager set was: `ContainerPDP`, `ProductDetails` (mobile), `ShopTogether` (above the fold). All `ProductShelfPDP`, `Reviews`, etc. stayed deferred.
133+
134+
---
135+
136+
## Lever 3 — Replace `createDecoRouter` with `createTanStackRouter` Direct
137+
138+
This is the **highest-leverage change** for perceived speed.
139+
140+
### The Problem
141+
142+
`createDecoRouter` from `@decocms/start/sdk/router` only exposes a subset of TanStack Router options. Notably missing:
143+
144+
- `defaultPreloadStaleTime` — how long a prefetched route stays "fresh" (default ~30s in dev mode is too short and inconsistent)
145+
- `defaultPreloadGcTime` — how long a prefetched route stays in memory
146+
- `defaultPreloadDelay` — debounce before firing prefetch
147+
148+
Without `defaultPreloadStaleTime` set, the prefetch fired on hover may be considered stale by the time the user clicks, causing a **second fetch** on click. The hover prefetch becomes wasted work.
149+
150+
### The Fix
151+
152+
Replace `createDecoRouter` with `createTanStackRouter` directly, reusing the Deco search parsers (the only meaningful thing the wrapper added):
153+
154+
```tsx
155+
// src/router.tsx
156+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
157+
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
158+
import {
159+
decoParseSearch,
160+
decoStringifySearch,
161+
} from "@decocms/start/sdk/router";
162+
import { routeTree } from "./routeTree.gen";
163+
import "./setup";
164+
165+
const queryClient = new QueryClient({
166+
defaultOptions: { queries: { staleTime: 60_000 } },
167+
});
168+
169+
export function getRouter() {
170+
return createTanStackRouter({
171+
routeTree,
172+
scrollRestoration: true,
173+
defaultPreload: "intent",
174+
defaultPreloadStaleTime: 60_000,
175+
defaultPreloadGcTime: 5 * 60_000,
176+
context: { queryClient } as any,
177+
Wrap: ({ children }) => (
178+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
179+
),
180+
parseSearch: decoParseSearch,
181+
stringifySearch: decoStringifySearch,
182+
});
183+
}
184+
185+
declare module "@tanstack/react-router" {
186+
interface Register {
187+
router: ReturnType<typeof getRouter>;
188+
}
189+
}
190+
```
191+
192+
### What Changes
193+
194+
- **Hover** → prefetch fires after 50ms → result cached for 60s
195+
- **Click within 60s** → router serves from cache → **navigation is instant**, no second fetch
196+
- **Click after 60s** → router refetches (cache expired)
197+
198+
For e-commerce, 60s is a sweet spot: typical hover → click latency is <1s, but users may return to a category page and click a different product within a minute. The cache covers both flows.
199+
200+
### Risk
201+
202+
`createDecoRouter` is a thin wrapper. The only Deco-specific behavior was the search parser (URLSearchParams-style for VTEX filter URLs). Since we re-import `decoParseSearch` and `decoStringifySearch`, behavior is preserved. If future framework versions add more to `createDecoRouter`, we miss it — review when upgrading `@decocms/start`.
203+
204+
---
205+
206+
## Lever 4 — LRU Cache on Heavy Loaders
207+
208+
Even with prefetch and eager sections, the loader still has to complete at least once per worker lifetime per product. If the loader does multiple HTTP HEAD requests (thumbnail format detection) or external API calls (Vimeo oEmbed), these become the bottleneck.
209+
210+
### Pattern: Module-Scope LRU Cache
211+
212+
```ts
213+
// src/loaders/checkAndReturnThumbnail360.ts
214+
const CACHE_MAX = 500;
215+
const formatCache = new Map<string, string | null>();
216+
217+
function cacheGet(key: string): string | null | undefined {
218+
const value = formatCache.get(key);
219+
if (value !== undefined) {
220+
// LRU: re-insert to move to most-recent end
221+
formatCache.delete(key);
222+
formatCache.set(key, value);
223+
}
224+
return value;
225+
}
226+
227+
function cacheSet(key: string, value: string | null): void {
228+
if (formatCache.size >= CACHE_MAX) {
229+
const oldestKey = formatCache.keys().next().value;
230+
if (oldestKey !== undefined) formatCache.delete(oldestKey);
231+
}
232+
formatCache.set(key, value);
233+
}
234+
235+
export default async function loader({ productID }: Props): Promise<string | null> {
236+
if (!productID) return null;
237+
238+
const cached = cacheGet(productID);
239+
if (cached !== undefined) return cached;
240+
241+
const validExtension = await findValidImageExtension(productID);
242+
cacheSet(productID, validExtension);
243+
return validExtension;
244+
}
245+
```
246+
247+
### Why Module-Scope and Not LRU Library
248+
249+
- Map's insertion order semantics give us free LRU with two extra lines
250+
- No dependency added, no bundle size impact
251+
- The cache lives for the worker lifetime — Cloudflare Workers recycle, but a single warm worker handles many requests
252+
253+
### What to Cache
254+
255+
| Loader | Cache key | Why cache |
256+
|--------|-----------|-----------|
257+
| `checkAndReturnThumbnail360` | productID | 6 HEAD requests to detect file extension — practically immutable per product |
258+
| `vimeo` | contentUrl | Vimeo oEmbed metadata never changes for a published video |
259+
| `accessories`/`attachments`/etc. | productID + variant | Only if profile shows them as hot |
260+
261+
### Also: Reduce Work, Not Just Cache It
262+
263+
The 360 thumbnail loader was checking 6 extensions (`.jpg`, `.webp`, `.png`, `.jpeg`, `.bmp`, `.tiff`). Production catalogs almost always use `.jpg` or `.webp`. Reducing the list to 2 cut first-load cost by 66% before the cache even kicks in:
264+
265+
```ts
266+
const extensionImageList = [".jpg", ".webp"];
267+
```
268+
269+
---
270+
271+
## Lever 5 — Reserved-Height LoadingFallback (Anti-CLS)
272+
273+
If you keep some sections deferred (Lever 2 only on a subset), the `LoadingFallback` must reserve approximate height — otherwise the footer flies up to the header, then content arrives and pushes everything down. This is a massive CLS hit.
274+
275+
### Bad
276+
277+
```tsx
278+
export function LoadingFallback() {
279+
return null; // or <div className="h-0 w-0" />
280+
}
281+
```
282+
283+
### Good
284+
285+
```tsx
286+
export function LoadingFallback() {
287+
// Reserve approximate PDP height for both mobile and desktop
288+
return <div className="min-h-[1100px] lg:min-h-[716px] w-full" />;
289+
}
290+
```
291+
292+
### How to Pick the Height
293+
294+
- Inspect the rendered PDP in DevTools, get the main container height
295+
- Use `min-h-` not `h-` to allow for content larger than expected
296+
- Use Tailwind responsive variants (`lg:min-h-[...]`) since mobile is usually taller (vertical stacked layout)
297+
- For shelves, use the slider height (typically ~400-500px)
298+
299+
### When Combined with Eager
300+
301+
If a section is `eager: true`, the `LoadingFallback` is rarely shown (only during SSR streaming gaps). Reserving height is still good defense — it costs nothing and protects against edge cases like slow first-paint.
302+
303+
---
304+
305+
## End-to-End Verification
306+
307+
After applying all five levers, validate in DevTools:
308+
309+
1. **Prefetch fires on hover**
310+
- Open DevTools → Network tab → filter `_serverFn`
311+
- Hover a product card for ~100ms
312+
- You should see a request to the catch-all route fire within ~50ms
313+
314+
2. **Click within 60s uses cache**
315+
- After hovering, wait 1-2 seconds
316+
- Click the card
317+
- **No new request should fire** — the loader response is served from the router's preload cache
318+
- PDP appears already populated (eager + cache = atomic swap)
319+
320+
3. **No CLS on navigation**
321+
- Use Chrome's Performance tab → Web Vitals
322+
- Navigate to a PDP, observe CLS metric
323+
- Should be < 0.05 (Good range)
324+
- The footer should not visibly "jump"
325+
326+
4. **Cold worker first hit**
327+
- Open an incognito window (cold worker, empty cache)
328+
- Navigate to a PDP
329+
- Should still feel fast — the 360 loader does 2 HEADs instead of 6, Vimeo fetches once, etc.
330+
331+
5. **Second visit to same PDP**
332+
- Navigate to PDP A → back → PDP A again
333+
- Second navigation should be measurably faster than first (LRU caches hit)
334+
335+
---
336+
337+
## Trade-offs and Risks
338+
339+
| Choice | Trade-off |
340+
|--------|-----------|
341+
| `eager: true` on PDP | Page stays on current URL longer; relies on `NavigationProgress` for feedback. Acceptable if loader is <1s after prefetch hits cache. |
342+
| `defaultPreloadStaleTime: 60_000` | Users on slow connections may see stale price/stock for up to 60s. Acceptable for retail; tune lower for flash sales. |
343+
| LRU cache module-scope | Cache does not survive cold starts; warm workers serve from cache. Fine for Cloudflare Workers' typical lifetime. |
344+
| Reducing thumbnail formats to 2 | Catalogs with legacy `.png`/`.tiff` 360 images break. Verify your catalog before applying. |
345+
| Bypass `createDecoRouter` | Future Deco router improvements (e.g., custom middleware) require updating this site's `router.tsx` manually. Document with a comment. |
346+
347+
---
348+
349+
## Files Typically Modified
350+
351+
```
352+
src/router.tsx # Lever 3
353+
src/sections/Product/ContainerPDP.tsx # Lever 2 + 5
354+
src/sections/Product/ProductDetails.tsx # Lever 2 + 5
355+
src/sections/Product/ShopTogether.tsx # Lever 2
356+
src/sections/Product/ProductShelfPDP.tsx # Lever 5
357+
src/components/product/card/ProductCard.tsx # Lever 1
358+
src/components/product/card/ProductCardImage.tsx # Lever 1
359+
src/components/product/card/ProductCardActions.tsx # Lever 1
360+
src/components/product/<other card variants> # Lever 1
361+
src/loaders/checkAndReturnThumbnail360.ts # Lever 4
362+
src/loaders/vimeo.ts # Lever 4
363+
src/server/cms/sections.gen.ts # regenerated after Lever 2
364+
```
365+
366+
---
367+
368+
## Next Steps If Still Slow
369+
370+
If after all five levers the PDP still feels slow, the bottleneck is now the loader itself. Profile the remaining parallel calls:
371+
372+
```ts
373+
// Add timing logs to identify the slowest fetch
374+
const start = Date.now();
375+
const result = await someLoader();
376+
console.log(`someLoader took ${Date.now() - start}ms`);
377+
```
378+
379+
Common next-step optimizations:
380+
381+
- **Move non-critical loaders to separate deferred sections.** `accessories`, `attachments`, `productSuggestions`, `similars` can each become their own deferred section so they don't block the eager `ContainerPDP`.
382+
- **Cache cross-sell results** (`vtexRelatedProducts`) the same way as Vimeo — by productID + crossSelling type.
383+
- **Reduce simulate calls.** VTEX's simulate endpoint is slow; if you only need price/stock, fetch it less aggressively.
384+
385+
---
386+
387+
## Related Skills
388+
389+
| Skill | Purpose |
390+
|-------|---------|
391+
| `deco-variant-selection-perf` | Avoid double-fetch on variant clicks (related navigation pattern) |
392+
| `deco-cms-layout-caching` | Cache Header/Footer to avoid layout re-resolution on every navigation |
393+
| `deco-vtex-fetch-cache` | SWR-style in-flight dedup for VTEX API calls |
394+
| `deco-loader-n-plus-1-detector` | Find loops doing N+1 API calls in section loaders |
395+
| `deco-edge-caching` | Configure Cloudflare Worker cache for commerce pages |
396+
| `deco-cms-route-config` | `cmsRouteConfig` + `ignoreSearchParams` for stable cache keys |

0 commit comments

Comments
 (0)