docs: frontend overhaul design spec
This commit is contained in:
304
docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md
Normal file
304
docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# Frontend Overhaul Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-15
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The backend `/feed` endpoint responds in ~10ms, but the frontend renders the page noticeably later. Three root causes:
|
||||||
|
|
||||||
|
1. **Author profile waterfall** — every page that displays thoughts fetches N separate `getUserProfile` calls after the main feed query, sequentially. 4+ pages duplicate this pattern verbatim.
|
||||||
|
2. **Scattered cache invalidation** — 5 isolated `router.refresh()` calls with no shared abstraction. Full page re-render on every mutation regardless of what actually changed.
|
||||||
|
3. **Broken composition** — `authorDetails: Map<string, {...}>` prop-drilled 4+ levels. Three components over 200 lines. Empty/loading states copy-pasted 4+ times each. `PostThoughtForm` and `ReplyForm` are near-identical duplicates.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Server Actions + `revalidateTag`** — idiomatic Next.js 15 + React 19 solution.
|
||||||
|
|
||||||
|
- Backend embeds author data in all response types → waterfall eliminated at the source
|
||||||
|
- Fetch cache tagged by data domain → mutations revalidate exactly what changed, not the whole page
|
||||||
|
- Server Actions replace client-side fetch + `router.refresh()` → one invalidation point per mutation
|
||||||
|
- `useOptimistic` (React 19) → instant UI feedback for toggle interactions
|
||||||
|
- Component splits and shared primitives → composition fixed
|
||||||
|
|
||||||
|
No new dependencies added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Backend — Embed Author Data Everywhere
|
||||||
|
|
||||||
|
### New type in `crates/api-types/`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AuthorResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response types updated
|
||||||
|
|
||||||
|
| Response type | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ThoughtResponse` | Add `author: AuthorResponse` |
|
||||||
|
| `NotificationResponse` | Add `actor: AuthorResponse` |
|
||||||
|
| `BoostResponse` / `LikeResponse` | Add `actor: AuthorResponse` if referencing a user |
|
||||||
|
|
||||||
|
### Architecture constraints
|
||||||
|
|
||||||
|
- Business logic stays in application use cases, not handlers.
|
||||||
|
- **Feed/list queries:** `PgFeedRepository` already joins the users table. `row_to_entry` populates author fields from the existing join — no extra queries.
|
||||||
|
- **Individual thought lookups:** Use cases that return a single thought compose `ThoughtRepository + UserReader` to produce a `ThoughtWithAuthor` output struct. The use case already takes both ports.
|
||||||
|
- **Handlers** only map: `ThoughtWithAuthor → ThoughtResponse { author: AuthorResponse }`. No DB access.
|
||||||
|
|
||||||
|
### Frontend impact
|
||||||
|
|
||||||
|
`lib/api.ts` Zod schemas updated to match. The `authorDetails: Map<string, {...}>` pattern, all `getUserProfile` calls inside page components, and all `Promise.all([...getUserProfile])` waterfalls are deleted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Frontend — Tagged Fetch Cache
|
||||||
|
|
||||||
|
Replace bare `fetch()` calls with tagged Next.js fetch so `revalidateTag` can target them precisely.
|
||||||
|
|
||||||
|
### `apiFetch` signature addition
|
||||||
|
|
||||||
|
```ts
|
||||||
|
apiFetch(path, token, options?: RequestInit & { next?: NextFetchRequestConfig })
|
||||||
|
```
|
||||||
|
|
||||||
|
The `next` field passes through to `fetch()`. No new dependencies.
|
||||||
|
|
||||||
|
### Tag taxonomy
|
||||||
|
|
||||||
|
| Tag | Invalidated when |
|
||||||
|
|---|---|
|
||||||
|
| `feed` | thought posted, deleted, edited, boosted |
|
||||||
|
| `profile:{username}` | profile edited, follow/unfollow of that user |
|
||||||
|
| `thoughts:{id}` | thought edited, deleted, replied to |
|
||||||
|
| `tags:{name}` | new thought containing that tag |
|
||||||
|
| `notifications` | any interaction received |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/api.ts
|
||||||
|
const feed = await apiFetch('/feed', token, { next: { tags: ['feed'] } })
|
||||||
|
const profile = await apiFetch(`/users/${username}`, token, {
|
||||||
|
next: { tags: [`profile:${username}`] }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Mutation Layer — Server Actions
|
||||||
|
|
||||||
|
All mutations become Server Actions in `app/actions/`. Each action calls the backend, then revalidates exactly the affected tags.
|
||||||
|
|
||||||
|
### File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/actions/
|
||||||
|
thoughts.ts — createThought, deleteThought, editThought
|
||||||
|
social.ts — followUser, unfollowUser, likeThought, boostThought
|
||||||
|
profile.ts — updateProfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/actions/thoughts.ts
|
||||||
|
'use server'
|
||||||
|
import { revalidateTag } from 'next/cache'
|
||||||
|
|
||||||
|
export async function createThought(formData: FormData) {
|
||||||
|
const token = await getToken()
|
||||||
|
await apiFetch('/thoughts', token, { method: 'POST', body: parseFormData(formData) })
|
||||||
|
revalidateTag('feed')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteThought(thoughtId: string, authorUsername: string) {
|
||||||
|
const token = await getToken()
|
||||||
|
await apiFetch(`/thoughts/${thoughtId}`, token, { method: 'DELETE' })
|
||||||
|
revalidateTag('feed')
|
||||||
|
revalidateTag(`thoughts:${thoughtId}`)
|
||||||
|
revalidateTag(`profile:${authorUsername}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/actions/social.ts
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
export async function followUser(username: string) {
|
||||||
|
const token = await getToken()
|
||||||
|
await apiFetch(`/users/${username}/follow`, token, { method: 'POST' })
|
||||||
|
revalidateTag(`profile:${username}`)
|
||||||
|
revalidateTag('feed')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function likeThought(thoughtId: string) {
|
||||||
|
const token = await getToken()
|
||||||
|
await apiFetch(`/thoughts/${thoughtId}/like`, token, { method: 'POST' })
|
||||||
|
revalidateTag(`thoughts:${thoughtId}`)
|
||||||
|
revalidateTag('feed')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Components drop `router.refresh()` and the `useRouter` import entirely. They call the Server Action directly (or pass it as a prop). The `router` dependency disappears from all mutation components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Optimistic Updates — `useOptimistic`
|
||||||
|
|
||||||
|
React 19's `useOptimistic` used for toggle interactions where latency is most noticeable: like, boost, follow.
|
||||||
|
|
||||||
|
### Like/boost (in `ThoughtCardActions`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export function ThoughtActions({ thought }: { thought: ThoughtResponse }) {
|
||||||
|
const [optimisticLiked, addOptimisticLike] = useOptimistic(thought.liked_by_viewer)
|
||||||
|
const [optimisticLikes, addOptimisticCount] = useOptimistic(thought.likes_count)
|
||||||
|
|
||||||
|
async function handleLike() {
|
||||||
|
addOptimisticLike(!optimisticLiked)
|
||||||
|
addOptimisticCount(optimisticLiked ? optimisticLikes - 1 : optimisticLikes + 1)
|
||||||
|
await likeThought(thought.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleLike}>
|
||||||
|
{optimisticLiked ? '♥' : '♡'} {optimisticLikes}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Follow button
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export function FollowButton({ username, initialFollowing }: Props) {
|
||||||
|
const [optimisticFollowing, addOptimistic] = useOptimistic(initialFollowing)
|
||||||
|
|
||||||
|
async function handleFollow() {
|
||||||
|
addOptimistic(!optimisticFollowing)
|
||||||
|
await (optimisticFollowing ? unfollowUser(username) : followUser(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleFollow}>{optimisticFollowing ? 'Unfollow' : 'Follow'}</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scope:** optimistic updates for like, boost, follow only. `createThought` and `deleteThought` do not get optimistic treatment — tagged cache revalidation is fast enough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Composition
|
||||||
|
|
||||||
|
### `ThoughtCard` split
|
||||||
|
|
||||||
|
```
|
||||||
|
components/thought-card/
|
||||||
|
index.tsx — assembles sub-components, no logic
|
||||||
|
header.tsx — avatar, username, display name, timestamp
|
||||||
|
body.tsx — content, hashtag links, content warning toggle
|
||||||
|
actions.tsx — like, boost, reply, delete; owns useOptimistic (client component)
|
||||||
|
```
|
||||||
|
|
||||||
|
Author data comes from `ThoughtResponse.author` directly. No `authorDetails` map, no prop drilling.
|
||||||
|
|
||||||
|
### Unified `ThoughtForm`
|
||||||
|
|
||||||
|
Replaces `PostThoughtForm` and `ReplyForm` (near-identical duplicates).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/thought-form.tsx
|
||||||
|
type Props = {
|
||||||
|
action: (formData: FormData) => Promise<void> // Server Action passed in
|
||||||
|
placeholder?: string
|
||||||
|
replyTo?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call sites:
|
||||||
|
```tsx
|
||||||
|
<ThoughtForm action={createThought} placeholder="What's on your mind?" />
|
||||||
|
<ThoughtForm action={replyToThought.bind(null, thoughtId)} replyTo={author} />
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostThoughtForm` and `ReplyForm` files are deleted.
|
||||||
|
|
||||||
|
### Shared primitives
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/empty-state.tsx
|
||||||
|
export function EmptyState({ message }: { message: string }) { ... }
|
||||||
|
|
||||||
|
// components/loading-skeleton.tsx
|
||||||
|
export function ThoughtSkeleton() { ... }
|
||||||
|
export function ProfileSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
4 copy-pasted empty state blocks replaced by `<EmptyState message="No thoughts yet." />`. Repeated loading card JSX replaced by `<ThoughtSkeleton />`.
|
||||||
|
|
||||||
|
### `RemoteUserProfile` split
|
||||||
|
|
||||||
|
```
|
||||||
|
components/remote-user-profile/
|
||||||
|
index.tsx — tab state only (client)
|
||||||
|
profile-card.tsx — avatar, bio, stats (server)
|
||||||
|
connections.tsx — followers/following lists with Suspense boundary (client)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### Backend (`crates/`)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `crates/api-types/src/responses.rs` | Add `AuthorResponse`; embed in `ThoughtResponse`, `NotificationResponse` |
|
||||||
|
| `crates/adapters/postgres/src/feed.rs` | Enrich `row_to_entry` with author columns from existing join |
|
||||||
|
| `crates/application/src/use_cases/thoughts.rs` | Return `ThoughtWithAuthor` from relevant use cases |
|
||||||
|
| `crates/presentation/src/handlers/thoughts.rs` | Map `ThoughtWithAuthor → ThoughtResponse`; remove secondary user fetches |
|
||||||
|
| `crates/presentation/src/handlers/feed.rs` | Same mapping update |
|
||||||
|
| `crates/presentation/src/handlers/notifications.rs` | Embed actor in notification responses |
|
||||||
|
|
||||||
|
### Frontend (`thoughts-frontend/`)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `lib/api.ts` | Add `next` option to `apiFetch`; update Zod schemas with `AuthorResponse` |
|
||||||
|
| `app/actions/thoughts.ts` | New — Server Actions for thought mutations |
|
||||||
|
| `app/actions/social.ts` | New — Server Actions for social interactions |
|
||||||
|
| `app/actions/profile.ts` | New — Server Actions for profile mutations |
|
||||||
|
| `app/page.tsx` | Remove author fetch waterfall; use tagged fetch |
|
||||||
|
| `app/users/[username]/page.tsx` | Same |
|
||||||
|
| `app/thoughts/[thoughtId]/page.tsx` | Same |
|
||||||
|
| `app/tags/[tagName]/page.tsx` | Same |
|
||||||
|
| `app/search/page.tsx` | Same |
|
||||||
|
| `components/thought-card/` | Split into header/body/actions |
|
||||||
|
| `components/thought-form.tsx` | New unified form |
|
||||||
|
| `components/post-thought-form.tsx` | Deleted |
|
||||||
|
| `components/reply-form.tsx` | Deleted |
|
||||||
|
| `components/empty-state.tsx` | New shared primitive |
|
||||||
|
| `components/loading-skeleton.tsx` | New shared primitive |
|
||||||
|
| `components/follow-button.tsx` | Remove `router.refresh()`; use Server Action + `useOptimistic` |
|
||||||
|
| `components/remote-user-profile/` | Split into profile-card/connections |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Does Not Cover
|
||||||
|
|
||||||
|
- Streaming / Suspense boundaries between sidebar widgets (future, lower priority once waterfall is gone)
|
||||||
|
- Search result caching (search is user-input-driven; less predictable tag invalidation)
|
||||||
|
- Pagination beyond the current page-based approach
|
||||||
Reference in New Issue
Block a user