From 7f10349c76e95745fff1eb6e599db6fd4add8983 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 01:59:16 +0200 Subject: [PATCH] docs: suspense + streaming design spec --- .../2026-05-16-suspense-streaming-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-suspense-streaming-design.md diff --git a/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md b/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md new file mode 100644 index 0000000..4f98e9e --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md @@ -0,0 +1,173 @@ +# Suspense Boundaries & Streaming Design + +**Date:** 2026-05-16 +**Status:** Approved + +## Problem + +The home feed page (`app/page.tsx`) blocks rendering until all data is ready: +- `getFeed` + `getMe` — critical path, fine +- `getFriends` + `getTopFriends` — sidebar data, runs **after** the feed fetch, adds 150–500ms before any HTML ships +- `PopularTags`, `TopFriends`, `UsersCount` are async server components that all resolve before the page renders + +Additionally, `TopFriends` has an N+1 problem: it fetches a list of usernames then makes N separate `getUserProfile` calls in parallel (~500ms–2s+ for 5–10 friends). + +No React Suspense boundaries exist anywhere in the app. `ThoughtSkeleton` and `ProfileSkeleton` are available from the previous overhaul. + +## Solution + +1. **Backend:** enrich `/users/{username}/top-friends` to return full `UserResponse` objects — eliminating the N+1 +2. **Frontend:** move sidebar fetches into the sidebar components themselves, wrap each in ``, strip secondary fetches from `app/page.tsx` + +--- + +## Section 1: Backend — Enrich top-friends endpoint + +### Response type change + +```rust +// crates/api-types/src/responses.rs +pub struct TopFriendsResponse { + pub top_friends: Vec, +} +``` + +Replaces the existing `{ topFriends: string[] }` shape. Same URL: `GET /users/{username}/top-friends`. + +### Use case change + +The existing use case calls `TopFriendRepository::get_top_friends(username)` returning `Vec`. Add a `UserRepository::find_many(&[UserId])` call (or a single JOIN query) to resolve the full user objects in one extra query. No N+1. + +### Files changed + +| File | Change | +|---|---| +| `crates/api-types/src/responses.rs` | Add `TopFriendsResponse { top_friends: Vec }` | +| `crates/domain/src/ports.rs` | Add `find_many(ids: &[UserId])` to `UserRepository` (or equivalent) | +| `crates/adapters/postgres/src/user.rs` | Implement `find_many` with `WHERE id = ANY($1)` | +| `crates/application/src/use_cases/users.rs` | Update top-friends use case to return `Vec` | +| `crates/presentation/src/handlers/users.rs` | Map `Vec` → `TopFriendsResponse` | + +--- + +## Section 2: Frontend — Suspense boundaries + self-contained sidebars + +### Strip `app/page.tsx` + +Remove `getFriends`, `getTopFriends`, `shouldDisplayTopFriends`, and the conditional sidebar rendering logic. Page-level critical path becomes: + +```ts +const [feedData, me] = await Promise.all([ + getFeed(token, page).catch(() => null), + getMe(token).catch(() => null), +]) +``` + +Pass `me.username` and `token` as props to `TopFriends`. + +### Self-contained `TopFriends` + +`TopFriends` receives `username: string` and `token: string` as props and fetches `getTopFriends(username, token)` internally. The response is now `{ topFriends: UserResponse[] }` — render directly, no secondary fetches. + +### Suspense wrapping (both desktop and mobile sidebars) + +```tsx +}> + + +}> + + +}> + + +``` + +Each widget streams in independently. Feed renders as soon as `getFeed` resolves. + +### New skeleton variants + +Add to `components/loading-skeleton.tsx`: + +```tsx +export function TagsSkeleton() { + return ( + + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ) +} + +export function CountSkeleton() { + return ( + + + + + + ) +} +``` + +### `loading.tsx` files for pages missing them + +Currently only `/users/[username]/loading.tsx` exists. Add: + +| Page | Skeleton content | +|---|---| +| `app/loading.tsx` | `ThoughtSkeleton` × 3 | +| `app/tags/[tagName]/loading.tsx` | `ThoughtSkeleton` × 3 | +| `app/search/loading.tsx` | `ThoughtSkeleton` × 3 | +| `app/thoughts/[thoughtId]/loading.tsx` | `ThoughtSkeleton` × 2 | + +### Update `lib/api.ts` + +```ts +// Before +export const getTopFriends = (username: string, token: string | null) => + apiFetch(..., z.object({ topFriends: z.array(z.string()) }), token) + +// After +export const getTopFriends = (username: string, token: string | null) => + apiFetch(..., z.object({ topFriends: z.array(UserSchema) }), token) +``` + +--- + +## Files Changed + +### Backend + +| File | Change | +|---|---| +| `crates/api-types/src/responses.rs` | `TopFriendsResponse { top_friends: Vec }` | +| `crates/domain/src/ports.rs` | `UserRepository::find_many` | +| `crates/adapters/postgres/src/user.rs` | `find_many` impl | +| `crates/application/src/use_cases/users.rs` | Return `Vec` from top-friends use case | +| `crates/presentation/src/handlers/users.rs` | Map to `TopFriendsResponse` | + +### Frontend + +| File | Change | +|---|---| +| `thoughts-frontend/lib/api.ts` | Update `getTopFriends` schema | +| `thoughts-frontend/app/page.tsx` | Strip sidebar fetches; pass `username`+`token` to TopFriends | +| `thoughts-frontend/components/top-friends.tsx` | Self-contained fetch; use `UserResponse[]` | +| `thoughts-frontend/components/loading-skeleton.tsx` | Add `TagsSkeleton`, `CountSkeleton` | +| `thoughts-frontend/app/loading.tsx` | New — feed skeleton | +| `thoughts-frontend/app/tags/[tagName]/loading.tsx` | New | +| `thoughts-frontend/app/search/loading.tsx` | New | +| `thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx` | New | + +--- + +## What This Does Not Cover + +- Suspense on the feed content itself (feed is already the critical path — streaming it would show partial thought lists, which is awkward UX) +- Left sidebar "Filters & Sorting" placeholder (still empty) +- Streaming on other pages beyond loading.tsx coverage