# Performance Plan: Reduce TTL + Fast Navigation ## Context Every route in the app is dynamically rendered because the root layout (`app/layout.tsx`) calls `headers()` and `getIdentityData()`. The `staleTimes: { dynamic: 600 }` config means revisiting a previously-loaded page within 10 min is instant (Router Cache), but **first visits** to sibling pages are slow because each page.tsx re-runs `getIdentityData()` — a deeply nested Supabase query that fetches identity, home leaflet with all facts, publications, subscriptions, entitlements, and more. On the leaflet page, `generateMetadata` and the page component independently fetch the same data (doubled DB calls), and all secondary data (RSVP, polls) blocks the entire render. ## Phase 1: Deduplicate leaflet page fetches **Goal**: Eliminate doubled DB queries on every leaflet page load. **File: `app/[leaflet_id]/page.tsx`** `generateMetadata` (line 79) and `LeafletPage` (line 28) both call `get_leaflet_data.handler()` with the same `token_id`. Same for `get_facts` (lines 45 and 92). Since both run in the same request, wrapping in `cache()` deduplicates them: ```ts import { cache } from "react"; const getCachedLeafletData = cache((token_id: string) => get_leaflet_data.handler({ token_id }, { supabase: supabaseServerClient }) ); const getCachedFacts = cache((root: string) => supabaseServerClient.rpc("get_facts", { root }) ); ``` Replace the 4 call sites (2 in page, 2 in generateMetadata) with these cached versions. --- ## Phase 2: Add loading.tsx skeletons **Goal**: Instant visual feedback during client-side navigation. Create `loading.tsx` files for: - `app/(home-pages)/home/loading.tsx` — grid skeleton matching leaflet cards - `app/(home-pages)/looseleafs/loading.tsx` — same skeleton - `app/(home-pages)/notifications/loading.tsx` — notification list skeleton - `app/[leaflet_id]/loading.tsx` — document skeleton These render immediately during route transitions while the page server component resolves. Reference the existing `app/(home-pages)/reader/loading.tsx` (uses `FeedSkeleton`) for the pattern. --- ## Phase 3: SpeedyLink on hot navigation paths **Goal**: Trigger prefetch on hover so clicks feel instant. **File: `app/(home-pages)/reader/layout.tsx`** (line 55) - Reader tab links (`Subs`, `Trending`, `New`) use plain ``. Replace with `SpeedyLink`. - SpeedyLink sets `prefetch={true}` on mouseEnter/pointerDown, triggering RSC prefetch before click. **File: `components/PostListing.tsx`** (line 14) - Post links in the feed use plain ``. Replace with `SpeedyLink` for the main post navigation link. - SpeedyLink needs to support the same props PostListing passes (currently just `href`, `className`, `children` — already supported). --- ## Phase 4: Cache `getIdentityData` across requests **Goal**: Navigating between sibling home-pages routes reuses identity data instead of re-querying DB. **File: `actions/getIdentityData.ts`** `unstable_cache` can't call `cookies()` inside the cached function (no request context when serving from cache). Split the function: ```ts import { unstable_cache } from "next/cache"; // Cached core — takes auth_token as param, no request context needed async function fetchIdentityByToken(auth_token: string) { // ... existing query logic using auth_token (lines 14-93) } // Request-scoped wrapper — reads cookies, calls cached core export const getIdentityData = cache(async () => { let cookieStore = await cookies(); let auth_token = cookieStore.get("auth_token")?.value || cookieStore.get("external_auth_token")?.value; if (!auth_token) return null; return unstable_cache( () => fetchIdentityByToken(auth_token), [`identity-${auth_token}`], { revalidate: 30, tags: [`identity-${auth_token}`] } )(); }); ``` - **Cache key**: `identity-${auth_token}` — per-user cache entry - **TTL**: 30 seconds — short enough for fresh notification counts, long enough for rapid navigation (home → reader → notifications without re-querying) - **Tags**: Enable manual invalidation from identity-mutating server actions - **Outer `cache()`**: Still deduplicates within a single request (layout + page) **Invalidation**: In actions that mutate identity-related data (login, logout, create/delete leaflet, subscribe/unsubscribe), add: ```ts revalidateTag(`identity-${auth_token}`); ``` Key actions to audit: - `actions/auth/` — login/logout - `actions/createLeaflet.ts` — adds to homepage - `actions/deleteLeaflet.ts` — removes from homepage - `actions/subscriptions/` — subscription changes - Any action that mutates `identities`, `permission_token_on_homepage`, `publications`, or `user_subscriptions` tables **No changes needed to consumers** — the function signature stays the same. All pages that call `getIdentityData()` automatically benefit. --- ## Phase 5: Suspense boundaries for secondary leaflet data **Goal**: Stream the main document content immediately, load RSVP/poll data in the background. **File: `app/[leaflet_id]/page.tsx`** Currently (line 44): ```ts let [{ data }, rsvp_data, poll_data] = await Promise.all([ getCachedFacts(rootEntity), getRSVPData(...), getPollData(...), ]); ``` Restructure to only await facts at the top level. Create an async component for secondary data: ```tsx async function SecondaryData({ entity_sets, leaflet_id, leaflet_data }: {...}) { let [rsvp_data, poll_data] = await Promise.all([ getRSVPData(entity_sets), getPollData(entity_sets), ]); return ( ); } ``` Then in the page: ```tsx }> ``` This depends on whether `PageSWRDataProvider` can be split so the Leaflet renders without RSVP/poll data initially. Check if those are optional — if the SWR provider just seeds client-side caches, the Leaflet should render fine without them initially. --- ## Verification 1. **Phase 1**: Open a leaflet page, check server logs — should see 1 call to `get_leaflet_data` and 1 call to `get_facts` instead of 2 each. 2. **Phase 2**: Navigate between home-pages routes — should see skeleton UI immediately during transition. 3. **Phase 3**: Hover over reader tabs / post listings — network tab should show prefetch requests. 4. **Phase 4**: Navigate home → reader → notifications rapidly — second and third navigations should be faster (check server logs for `getIdentityData` cache hits). Verify identity data is still fresh after login/logout. 5. **Phase 5**: Load a leaflet with polls/RSVP — main content should appear before poll/RSVP data resolves. 6. Run `npx tsc` and `npm run lint` after all changes. ## Critical files - `app/[leaflet_id]/page.tsx` - `actions/getIdentityData.ts` - `app/(home-pages)/reader/layout.tsx` - `components/PostListing.tsx` - `components/SpeedyLink.tsx` - `app/(home-pages)/reader/loading.tsx` (reference for skeleton pattern)