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

580 lines
17 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 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stream the feed page immediately while sidebar widgets load independently, and fix the N+1 top-friends profile fetch by having the backend return full user objects in one query.
**Architecture:** The `TopFriendRepository::list_for_user` already JOINs users — the backend just discards the user data and returns only usernames. We fix the handler to return `Vec<UserResponse>`. Frontend: `TopFriends` becomes self-contained (fetches its own data via cookies, no props from page), wrapped in `<Suspense>`. `app/page.tsx` drops all sidebar awaits, reducing critical-path work to two parallel calls.
**Tech Stack:** Rust/Axum (backend), Next.js 15 App Router, React 19 Suspense, Tailwind CSS, shadcn/ui
---
## Key Facts
- `TopFriendRepository::list_for_user` already returns `Vec<(TopFriend, User)>` — the JOIN with the users table is done. The handler just discards the `User` data.
- `to_user_response(u: &User) -> UserResponse` lives in `crates/presentation/src/handlers/auth.rs` as `pub fn` — import it where needed.
- Auth token in frontend server components: `(await cookies()).get("auth_token")?.value ?? null` via `next/headers`.
- Baseline test suite: `cargo test` (148 tests) in the backend; `npx tsc --noEmit` in `thoughts-frontend/`.
---
## File Map
| File | Change |
|---|---|
| `crates/api-types/src/responses.rs` | Add `TopFriendsResponse { top_friends: Vec<UserResponse> }` |
| `crates/presentation/src/handlers/social.rs` | Return `TopFriendsResponse` instead of `{ topFriends: usernames }` |
| `thoughts-frontend/lib/api.ts` | Update `getTopFriends` schema to `z.array(UserSchema)` |
| `thoughts-frontend/components/loading-skeleton.tsx` | Add `TagsSkeleton`, `CountSkeleton` |
| `thoughts-frontend/components/top-friends.tsx` | Self-contained: fetches own data, no `usernames` prop |
| `thoughts-frontend/app/page.tsx` | Strip `getFriends`/`getTopFriends`/`shouldDisplayTopFriends`; add Suspense |
| `thoughts-frontend/app/loading.tsx` | New — feed page skeleton |
| `thoughts-frontend/app/tags/[tagName]/loading.tsx` | New |
| `thoughts-frontend/app/search/loading.tsx` | New |
| `thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx` | New |
---
## Task 1: Add `TopFriendsResponse` to api-types
**Files:**
- Modify: `crates/api-types/src/responses.rs`
- [ ] **Step 1: Add the response struct**
In `crates/api-types/src/responses.rs`, after the `NotificationResponse` block, add:
```rust
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TopFriendsResponse {
pub top_friends: Vec<UserResponse>,
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cargo build -p api-types
```
Expected: Compiles with no errors.
- [ ] **Step 3: Commit**
```bash
git add crates/api-types/src/responses.rs
git commit -m "feat(api-types): TopFriendsResponse with Vec<UserResponse>"
```
---
## Task 2: Update `get_top_friends_handler` to return full user objects
**Files:**
- Modify: `crates/presentation/src/handlers/social.rs`
- [ ] **Step 1: Read the current handler**
Current code (around line 159167 in `social.rs`):
```rust
pub async fn get_top_friends_handler(
Deps(d): Deps<SocialDeps>,
Path(username): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?;
let friends = get_top_friends(&*d.top_friends, &user.id).await?;
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
Ok(Json(serde_json::json!({ "topFriends": usernames })))
}
```
`friends` is already `Vec<(TopFriend, User)>` — the user data is there.
- [ ] **Step 2: Add import and update the handler**
Add to the imports at the top of `social.rs`:
```rust
use api_types::responses::TopFriendsResponse;
use crate::handlers::auth::to_user_response;
```
Replace the handler body:
```rust
#[utoipa::path(get, path = "/users/{username}/top-friends",
params(("username" = String, Path, description = "Username")),
responses((status = 200, description = "Top friends list", body = TopFriendsResponse)))]
pub async fn get_top_friends_handler(
Deps(d): Deps<SocialDeps>,
Path(username): Path<String>,
) -> Result<Json<TopFriendsResponse>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?;
let friends = get_top_friends(&*d.top_friends, &user.id).await?;
let top_friends = friends.iter().map(|(_, u)| to_user_response(u)).collect();
Ok(Json(TopFriendsResponse { top_friends }))
}
```
- [ ] **Step 3: Run backend tests**
```bash
cargo test
```
Expected: 148 tests pass (same as before — no test exercises the response shape directly).
- [ ] **Step 4: Smoke-test the endpoint manually (optional)**
```bash
curl -s -H "Authorization: Bearer <your_token>" http://localhost:3001/users/me/top-friends | python3 -m json.tool
```
Expected: `{ "topFriends": [ { "id": "...", "username": "...", "avatarUrl": "...", ... } ] }`
- [ ] **Step 5: Commit**
```bash
git add crates/presentation/src/handlers/social.rs
git commit -m "fix(api): top-friends endpoint returns full UserResponse — eliminates frontend N+1"
```
---
## Task 3: Update frontend `getTopFriends` Zod schema
**Files:**
- Modify: `thoughts-frontend/lib/api.ts`
- [ ] **Step 1: Find and update `getTopFriends`**
In `lib/api.ts`, find `getTopFriends` (around line 232). Current:
```ts
export const getTopFriends = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/top-friends`,
{ next: { tags: [`profile:${username}`] } },
z.object({ topFriends: z.array(z.string()) }),
token
);
```
Replace with:
```ts
export const getTopFriends = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/top-friends`,
{ next: { tags: [`profile:${username}`] } },
z.object({ topFriends: z.array(UserSchema) }),
token
);
```
`UserSchema` is already defined in the same file — no new import needed.
- [ ] **Step 2: Type-check**
```bash
cd thoughts-frontend && npx tsc --noEmit
```
Expected: 0 errors. (TypeScript will flag call sites that use `getTopFriends` and expect `string[]` — these are fixed in Task 4.)
If errors: note them, they'll be resolved in Task 4.
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/lib/api.ts
git commit -m "fix(frontend): getTopFriends schema returns UserSchema[] not string[]"
```
---
## Task 4: Add `TagsSkeleton` and `CountSkeleton` to loading-skeleton.tsx
**Files:**
- Modify: `thoughts-frontend/components/loading-skeleton.tsx`
- [ ] **Step 1: Add the two new exports**
In `thoughts-frontend/components/loading-skeleton.tsx`, append after the existing exports:
```tsx
export function TagsSkeleton() {
return (
<Card>
<CardContent className="pt-4 space-y-2">
<Skeleton className="h-4 w-24 mb-3" />
{[...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 pb-4">
<Skeleton className="h-6 w-32" />
</CardContent>
</Card>
)
}
```
`Card`, `CardContent`, and `Skeleton` are already imported in the file.
- [ ] **Step 2: Type-check**
```bash
cd thoughts-frontend && npx tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/loading-skeleton.tsx
git commit -m "feat(frontend): TagsSkeleton and CountSkeleton for sidebar Suspense fallbacks"
```
---
## Task 5: Rewrite `TopFriends` to be self-contained
**Files:**
- Modify: `thoughts-frontend/components/top-friends.tsx`
- [ ] **Step 1: Replace the component**
Replace the entire file with:
```tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UserAvatar } from "./user-avatar";
import { getTopFriends } from "@/lib/api";
import { cookies } from "next/headers";
interface TopFriendsProps {
username: string;
}
export async function TopFriends({ username }: TopFriendsProps) {
const token = (await cookies()).get("auth_token")?.value ?? null;
const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] }));
const friends = data.topFriends;
if (friends.length === 0) return null;
return (
<Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
Top Friends
</CardTitle>
</CardHeader>
<CardContent id="top-friends__content" className="p-0">
{friends.map((friend) => (
<Link
id={`top-friends__link-${friend.id}`}
href={`/users/${friend.username}`}
key={friend.id}
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<UserAvatar src={friend.avatarUrl} alt={friend.username} />
<span
id={`top-friends__name-${friend.id}`}
className="text-xs truncate w-full font-medium text-shadow-sm"
>
{friend.displayName || friend.username}
</span>
</Link>
))}
</CardContent>
</Card>
);
}
```
Changes from old component:
- Props: was `{ mode, usernames: string[] }`, now `{ username: string }` — fetches its own data
- No more N+1 `getUserProfile` calls — renders `data.topFriends` (already `User[]`) directly
- `mode` prop removed — always shows "Top Friends" title (the "friends fallback" mode is dropped)
- Returns `null` if no top friends (sidebar just hides the widget)
- [ ] **Step 2: Type-check**
```bash
cd thoughts-frontend && npx tsc --noEmit
```
Expected: Errors at `app/page.tsx` call sites (still pass old props) — will be fixed in Task 6.
- [ ] **Step 3: Commit (even with type errors from call sites)**
```bash
git add thoughts-frontend/components/top-friends.tsx
git commit -m "refactor(frontend): TopFriends self-contained — fetches own data, no N+1"
```
---
## Task 6: Update `app/page.tsx` — strip blocking awaits + add Suspense
**Files:**
- Modify: `thoughts-frontend/app/page.tsx`
- [ ] **Step 1: Update imports**
Replace the current import block at the top of `app/page.tsx`:
```ts
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getFeed, getMe, Me } from "@/lib/api";
import { ThoughtForm } from "@/components/thought-form";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { PopularTags } from "@/components/popular-tags";
import { ThoughtThread } from "@/components/thought-thread";
import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count";
import { PaginationNav } from "@/components/pagination-nav";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
```
(Removed: `getFriends`, `getTopFriends`, `User`)
- [ ] **Step 2: Replace `FeedPage` function**
```tsx
async function FeedPage({
token,
searchParams,
}: {
token: string;
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page ?? "1", 10);
const [feedData, me] = await Promise.all([
getFeed(token, page).catch(() => null),
getMe(token).catch(() => null) as Promise<Me | null>,
]);
if (!feedData || !me) {
redirect("/login");
}
const { items: allThoughts, totalPages } = feedData!;
const thoughtThreads = buildThoughtThreads(allThoughts);
const sidebar = (
<>
<Suspense fallback={<ProfileSkeleton />}>
<TopFriends username={me.username} />
</Suspense>
<Suspense fallback={<TagsSkeleton />}>
<PopularTags />
</Suspense>
<Suspense fallback={<CountSkeleton />}>
<UsersCount />
</Suspense>
</>
);
return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
<h2 className="text-lg font-semibold">Filters &amp; Sorting</h2>
<p className="text-sm text-muted-foreground">Coming soon...</p>
</div>
</aside>
<main className="col-span-1 lg:col-span-2 space-y-6">
<header className="mb-6">
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
</header>
<ThoughtForm />
<div className="block lg:hidden space-y-6">
{sidebar}
</div>
<div className="space-y-6">
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
currentUser={me}
/>
))}
{thoughtThreads.length === 0 && (
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
)}
</div>
<PaginationNav
page={page}
totalPages={totalPages}
buildHref={(p) => `/?page=${p}`}
/>
</main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
{sidebar}
</div>
</aside>
</div>
</div>
);
}
```
- [ ] **Step 3: Type-check**
```bash
cd thoughts-frontend && npx tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add thoughts-frontend/app/page.tsx
git commit -m "perf(frontend): stream sidebar via Suspense — feed renders immediately, sidebar loads async"
```
---
## Task 7: Add `loading.tsx` files for pages missing them
**Files:**
- Create: `thoughts-frontend/app/loading.tsx`
- Create: `thoughts-frontend/app/tags/[tagName]/loading.tsx`
- Create: `thoughts-frontend/app/search/loading.tsx`
- Create: `thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx`
- [ ] **Step 1: Create `thoughts-frontend/app/loading.tsx`**
This matches the feed page layout (3-column grid with feed column visible):
```tsx
import { ThoughtSkeleton } from "@/components/loading-skeleton";
export default function FeedLoading() {
return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="hidden lg:block lg:col-span-1" />
<main className="col-span-1 lg:col-span-2 space-y-6">
<div className="h-10 w-32 bg-muted rounded animate-pulse mb-6" />
<div className="space-y-4">
<ThoughtSkeleton />
<ThoughtSkeleton />
<ThoughtSkeleton />
</div>
</main>
<aside className="hidden lg:block lg:col-span-1" />
</div>
</div>
);
}
```
- [ ] **Step 2: Create `thoughts-frontend/app/tags/[tagName]/loading.tsx`**
```tsx
import { ThoughtSkeleton } from "@/components/loading-skeleton";
export default function TagLoading() {
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
<div className="h-8 w-40 bg-muted rounded animate-pulse" />
<ThoughtSkeleton />
<ThoughtSkeleton />
<ThoughtSkeleton />
</div>
);
}
```
- [ ] **Step 3: Create `thoughts-frontend/app/search/loading.tsx`**
```tsx
import { ThoughtSkeleton } from "@/components/loading-skeleton";
export default function SearchLoading() {
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
<div className="h-8 w-48 bg-muted rounded animate-pulse" />
<ThoughtSkeleton />
<ThoughtSkeleton />
<ThoughtSkeleton />
</div>
);
}
```
- [ ] **Step 4: Create `thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx`**
```tsx
import { ThoughtSkeleton } from "@/components/loading-skeleton";
export default function ThoughtLoading() {
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
<ThoughtSkeleton />
<div className="pl-6 border-l-2 border-primary border-dashed space-y-4">
<ThoughtSkeleton />
<ThoughtSkeleton />
</div>
</div>
);
}
```
- [ ] **Step 5: Type-check**
```bash
cd thoughts-frontend && npx tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 6: Commit**
```bash
git add thoughts-frontend/app/loading.tsx \
thoughts-frontend/app/tags \
thoughts-frontend/app/search/loading.tsx \
thoughts-frontend/app/thoughts
git commit -m "feat(frontend): loading.tsx skeletons for feed, tags, search, and thread pages"
```
---
## Final Verification
- [ ] `cargo test` — 148 tests pass
- [ ] `cd thoughts-frontend && npx tsc --noEmit` — 0 errors
- [ ] `grep -r "usernames" thoughts-frontend/components/top-friends.tsx` — 0 results (old prop gone)
- [ ] `grep -r "getFriends\|getTopFriends\|shouldDisplayTopFriends" thoughts-frontend/app/page.tsx` — 0 results
- [ ] `grep -r "Suspense" thoughts-frontend/app/page.tsx` — 3+ results (one per sidebar widget)