|
| 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