From 896d2d86c9234b21c288a2dc3ba42fe4105f0d2b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 19:17:31 +0200 Subject: [PATCH] docs: frontend overhaul design spec --- .../2026-05-15-frontend-overhaul-design.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md diff --git a/docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md b/docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md new file mode 100644 index 0000000..15c591b --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md @@ -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` 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, + pub avatar_url: Option, +} +``` + +### 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` 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 ( + + ) +} +``` + +### 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 +} +``` + +**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 // Server Action passed in + placeholder?: string + replyTo?: string +} +``` + +Call sites: +```tsx + + +``` + +`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 ``. Repeated loading card JSX replaced by ``. + +### `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