Files
thoughts/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md

174 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 150500ms 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 (~500ms2s+ for 510 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 `<Suspense>`, 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<UserResponse>,
}
```
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<UserId>`. 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<UserResponse> }` |
| `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<User>` |
| `crates/presentation/src/handlers/users.rs` | Map `Vec<User>``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
<Suspense fallback={<ProfileSkeleton />}>
<TopFriends username={me.username} token={token} />
</Suspense>
<Suspense fallback={<TagsSkeleton />}>
<PopularTags />
</Suspense>
<Suspense fallback={<CountSkeleton />}>
<UsersCount />
</Suspense>
```
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 (
<Card>
<CardContent className="pt-4 space-y-2">
<Skeleton className="h-4 w-24" />
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-6 w-full rounded-full" />
))}
</CardContent>
</Card>
)
}
export function CountSkeleton() {
return (
<Card>
<CardContent className="pt-4">
<Skeleton className="h-6 w-32" />
</CardContent>
</Card>
)
}
```
### `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<UserResponse> }` |
| `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<User>` 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