From 7127ee4cab7add5ddae48df87830297d186b6c11 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 18 Aug 2026 16:29:40 -0400 Subject: [PATCH] Speed up memo navigation: loading skeleton + parallel fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a memo felt unresponsive in production. Two causes: The memo route reads cookies (draft preview, viewer state), so it renders dynamically on every request. With no loading boundary its prefetch payload was empty (239 bytes in prod), so a click did a full round trip with the previous page still on screen and no feedback — indistinguishable from a dead click. Adding loading.tsx makes the skeleton statically prefetchable, so the click paints immediately. The render itself was also needlessly serial. fetchMemo and fetchMemos are independent but ran one after the other, and fetchMemos paged through all 65 memos a request at a time before fetching /team. Page 1 now establishes the page count and the rest go out together, with /team started up front. Measured against the live API, same output (65 memos, 81 team members): ~1.9s sequential to ~0.9s parallel. Also fixes a latent bug in the pagination loop, which mutated one shared queryParams object — safe only while the calls were sequential. Related memos are now non-fatal: that list backs a decorative two-item sidebar and shouldn't 500 the whole memo. Matches how /team failures were already handled here. A bad slug still 404s. The skeleton renders per-memo content as placeholders but keeps invariant chrome real — the Key Messages frame, eyebrow and numerals, the Signpost rail, track and share buttons — so the page reads as a memo mid-load rather than a generic loading card. Its primitives live in globals.css for reuse. This does not address the underlying dynamic rendering, the 65-memo fetch behind two related memos, or /team taking ~1.1s upstream. Co-Authored-By: Claude Opus 5 --- src/app/globals.css | 37 ++++++ src/app/memos/[slug]/MemoSkeleton.tsx | 153 +++++++++++++++++++++++ src/app/memos/[slug]/loading.tsx | 9 ++ src/app/memos/[slug]/page.tsx | 15 ++- src/app/toronto/memos/[slug]/loading.tsx | 5 + src/app/toronto/memos/[slug]/page.tsx | 15 ++- src/lib/api/memos.ts | 42 ++++--- 7 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 src/app/memos/[slug]/MemoSkeleton.tsx create mode 100644 src/app/memos/[slug]/loading.tsx create mode 100644 src/app/toronto/memos/[slug]/loading.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 192d890..cdd3e96 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,43 @@ html { animation: fadeInUp 0.5s ease-out forwards; } +/* ─── Loading placeholders ─── */ + +/* A light band sweeping left→right, in the spirit of the reveal animations + below — quieter than a pulsing opacity blink, and it reads as "typesetting + in progress" rather than "widget buffering". */ +@keyframes skeletonSweep { + from { background-position: 150% 0; } + to { background-position: -150% 0; } +} + +/* Placeholder for a line of body text — light grey from the charcoal ramp, + one step lighter than --color-border-light so placeholders stay quieter + than the rules and frames around them. */ +.skeleton-bar { + background-color: var(--color-charcoal-200); + background-image: linear-gradient( + 90deg, + transparent 20%, + var(--color-charcoal-100) 50%, + transparent 80% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + animation: skeletonSweep 2s ease-in-out infinite; +} + +/* Heavier weight, for headings and other display type. */ +.skeleton-bar-strong { + background-color: var(--color-charcoal-300); +} + +/* Staggers the sweep down a stack so it travels through the block instead of + every line flashing in unison. */ +.skeleton-delay-1 { animation-delay: 0.15s; } +.skeleton-delay-2 { animation-delay: 0.3s; } +.skeleton-delay-3 { animation-delay: 0.45s; } + /* Draw stroke forward (dashoffset 1 → 0) */ @keyframes drawIn { from { stroke-dashoffset: 1; } diff --git a/src/app/memos/[slug]/MemoSkeleton.tsx b/src/app/memos/[slug]/MemoSkeleton.tsx new file mode 100644 index 0000000..48dfc5d --- /dev/null +++ b/src/app/memos/[slug]/MemoSkeleton.tsx @@ -0,0 +1,153 @@ +// The placeholder a memo route shows while its content streams in. +// +// It mirrors the real layout in page.tsx (hero → signpost → key messages → +// body) at the same widths and spacing, so swapping in the real memo doesn't +// shift anything on screen. +// +// Chrome that's identical on every memo — the Key Messages frame and its +// eyebrow, the numbered indices, the Signpost's rail and Share block — is +// rendered for real rather than faked as grey blocks. Only the parts that +// differ per memo are placeholders, which keeps the page recognisably a memo +// while it loads instead of a generic loading card. + +const KEY_MESSAGES_FILL = { + // The fills the real Key Messages boxes use (see page.tsx). Hardcoded there + // too — they predate the linen ramp. + default: "bg-[#f0e5dc]", + toronto: "bg-[#d7e4f3]", +} as const; + +const DELAYS = ["", "skeleton-delay-1", "skeleton-delay-2", "skeleton-delay-3"]; + +// One placeholder line. `i` staggers the sweep down a stack. +function Line({ + className, + strong = false, + i = 0, +}: { + className: string; + strong?: boolean; + i?: number; +}) { + return ( +
+ ); +} + +export function MemoSkeleton({ + brand = "default", + showBackLink = false, +}: { + brand?: keyof typeof KEY_MESSAGES_FILL; + showBackLink?: boolean; +}) { + return ( +
+ Loading memo… + +