174 lines
5.8 KiB
Markdown
174 lines
5.8 KiB
Markdown
# 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 `<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
|