diff --git a/AGENTS.md b/AGENTS.md index 82cb1b3..1dec450 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,13 +308,31 @@ Each feature package exposes exactly these subpath exports: | Subpath | What it exports | Who consumes | | ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- | | `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer | -| `./ui` | Query builders (`queryOptions`), UI components | App packages | +| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages | | `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only | | `./cms` | Payload collections | `@repo/core-cms` only | | `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only | | `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook | -Apps import schemas/types from `@repo/` (root) and React Query builders from `@repo//ui`. Deep source paths are not accessible — the `exports` map enforces this. +Apps import schemas/types from `@repo/` (root) and hooks/components from `@repo//ui`. Deep source paths are not accessible — the `exports` map enforces this. + +### Feature UI structure + +Each feature's `src/ui/` follows this layout: + +``` +src/ui/ + index.ts # Barrel — exports server components as public API + query.ts # Query builder functions (framework-agnostic) + hooks/ + use-.ts # "use client" — wraps useTRPC + useSuspenseQuery + components/ + -list.server.tsx # Server — DI + prefetch + HydrationBoundary (public) + -list.client.tsx # "use client" — calls hook (internal only) + -card.tsx # Presentational (receives props) +``` + +Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: ``, ``. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide. ### Payload-backed features use constructor injection diff --git a/CLAUDE.md b/CLAUDE.md index bb5107e..52fbeff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, - `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020) - `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021) - `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals +- `docs/guides/building-feature-ui.md` — Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiring - `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist ## Conformance system @@ -105,7 +106,8 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational - **Schemas in the use-case file** — Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data) - **Controllers receive `unknown` + presenter** — Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise>` (identity is fine — `return value`); void controllers return `Promise` with no presenter - **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes -- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo//ui`, schemas/types from `@repo/` +- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind `./ui` (`src/ui/index.ts`). Apps import hooks/components from `@repo//ui`, schemas/types from `@repo/` +- **Feature UI owns its data fetching** — Each feature's `src/ui/hooks/` contains `"use client"` hooks that wrap `useTRPC` + `useSuspenseQuery`. Connected components in `src/ui/components/` call these hooks. App pages prefetch via `appRouter.createCaller({})` and hydrate via `HydrationBoundary` + `dehydrate` + `setQueryData`. See `docs/guides/building-feature-ui.md` - **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency - **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev()` function that uses the feature's existing factory - **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders diff --git a/docs/guides/building-feature-ui.md b/docs/guides/building-feature-ui.md new file mode 100644 index 0000000..e01a7d0 --- /dev/null +++ b/docs/guides/building-feature-ui.md @@ -0,0 +1,498 @@ +# Building Feature UI — Components, Hooks & Data Fetching + +Each feature owns its UI layer inside `src/ui/`. This guide covers how to +create React components that fetch their own data via tRPC + React Query, +how to wire them into Next.js and TanStack Start apps, and how the server +prefetch + client hydration pattern works. + +> **Prerequisites:** `@repo/core-trpc` must be scaffolded +> (`pnpm turbo gen core-package trpc`). The tRPC providers must be wired +> into the app's root layout (see [App wiring](#app-wiring) below). + +--- + +## Feature `src/ui/` folder structure + +``` +packages//src/ui/ + index.ts # Barrel — re-exports server components as public API + query.ts # Query builder functions (framework-agnostic) + hooks/ + use-.ts # "use client" hooks wrapping tRPC + useSuspenseQuery + use--list.ts + components/ + -card.tsx # Presentational (receives props, no hooks) + -list.server.tsx # Server component — DI + prefetch + HydrationBoundary + -list.client.tsx # "use client" — calls hook, owns rendering + -detail.server.tsx + -detail.client.tsx +``` + +### Naming convention + +| File suffix | Directive | Role | Exported from barrel? | +| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `.server.tsx` | _(none — server by default)_ | Resolves controller from DI, prefetches data, wraps `.client` in `HydrationBoundary` | **Yes** — under the clean name (e.g. `ArticleList`) | +| `.client.tsx` | `"use client"` | Calls hooks, renders UI | **No** — internal to the feature; only imported by its `.server` counterpart | +| `.tsx` (no suffix) | _(none)_ | Presentational — receives data via props, no hooks | Yes, if useful standalone (e.g. `ArticleCard`) | + +The **server component is the public face** — the barrel exports it under +the clean component name (`ArticleList`, `ArticleDetail`, `PageContent`). +The `.client.tsx` suffix signals "internal, not for direct consumption" — +consumers never see it. + +### Component roles + +- **Server components** (`.server.tsx`) — resolve the controller from the + feature's DI container, call it to prefetch data, seed the React Query + cache via `setQueryData`, and wrap the client component in + `HydrationBoundary`. This gives SSR + instant hydration. +- **Client components** (`.client.tsx`) — `"use client"`. Call hooks from + `hooks/` to get data via `useSuspenseQuery`. Handle rendering + interactivity. + Never imported by app pages directly. +- **Hooks** (`hooks/`) — own data fetching; one hook per query. + Always `"use client"`. Import `useTRPC` from `@repo/core-trpc` and + `useSuspenseQuery` from `@tanstack/react-query`. +- **Presentational components** (`.tsx`, no suffix) — receive data via props. + No `"use client"` unless they need browser APIs. Can be shared by + multiple client components. + +--- + +## Component composition & `@repo/core-ui` reuse + +Feature components **must** reuse primitives from `@repo/core-ui` rather +than hand-rolling HTML with raw Tailwind classes. `core-ui` follows +**Atomic Design**: + +| Tier | Location | Examples | Rule | +| ------------- | ------------------------ | ----------------------------------- | --------------------------------------------- | +| **Atoms** | `core-ui/src/atoms/` | `Button`, `Input`, `Label` | Smallest building blocks. No business logic. | +| **Molecules** | `core-ui/src/molecules/` | `FormField` (Label + Input + error) | Compose atoms. Still generic. | +| **Organisms** | `core-ui/src/organisms/` | `CookieConsentBanner` | Compose molecules/atoms. May own local state. | +| **Templates** | `core-ui/src/templates/` | Page shells, layout grids | Structural — define slots, no data. | + +**Import direction is strictly upward:** atoms never import molecules; +molecules never import organisms. The ESLint rule +`atomic-tier-import-direction` enforces this. + +### Where feature components fit + +Feature components are **consumers** of core-ui, not replacements. +They sit above the atomic tiers: + +``` +App page (imports feature component, passes route props) + └── Feature server component (.server.tsx — DI + prefetch + HydrationBoundary) + └── Feature client component (.client.tsx — "use client", calls hook) + └── Feature presentational component (receives props) + └── core-ui atoms/molecules (Button, Input, FormField, ...) +``` + +**Guidelines:** + +- **Always check `core-ui` first.** Before creating a `` or + `` in a feature, check if `core-ui` already exports it. Use + Storybook (`pnpm dev --filter @repo/storybook`) or the barrel at + `packages/core-ui/src/index.ts`. +- **If a primitive is missing, add it to `core-ui`** — not to the + feature. Scaffold via `pnpm turbo gen core-ui-component`. Feature + packages should not contain generic UI primitives. +- **Feature components compose, not duplicate.** A feature's + `` should render a `core-ui` `` (when it exists) + with feature-specific content inside — not re-implement card styling. +- **Tailwind utility classes are fine** for layout and spacing within + feature components (flex, grid, padding, margin). But visual + primitives (buttons, inputs, badges, cards) come from `core-ui`. + +### Adding `core-ui` as a dependency + +Feature packages that use core-ui atoms need: + +```jsonc +// package.json +"dependencies": { + "@repo/core-ui": "workspace:*" +} +``` + +--- + +## Step 1: Create a hook + +Hooks live in `src/ui/hooks/` and wrap a single tRPC query: + +```typescript +// packages/blog/src/ui/hooks/use-article-list.ts +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useTRPC } from "@repo/core-trpc"; +import type { Article } from "../../entities/models/article"; + +export function useArticleList(options?: { + status?: "draft" | "published"; + limit?: number; +}) { + const trpc = useTRPC(); + return useSuspenseQuery( + trpc.blog.listArticles.queryOptions({ + status: options?.status ?? "published", + limit: options?.limit ?? 20, + }), + ) as { data: Article[] }; +} +``` + +> **TS2742 workaround:** Feature packages set `declaration: true` (from +> the base tsconfig). The `as { data: T }` cast avoids a non-portable +> return type error caused by `@trpc/client` resolving to different +> `.pnpm` paths per package. + +### Dependencies + +Feature packages that have hooks need these dependencies: + +```jsonc +// package.json +"dependencies": { + "@repo/core-trpc": "workspace:*", + "@tanstack/react-query": "^5.66.0", + "@trpc/client": "^11.17.0", // for type portability + "react": "^19.0.0" +} +``` + +--- + +## Step 2: Create components + +### Server component (public face) + +The server component resolves the controller from DI, prefetches, and +wraps the client component in `HydrationBoundary`: + +```typescript +// packages/blog/src/ui/components/article-list.server.tsx +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import { getQueryClient } from "@repo/core-trpc"; +import { blogContainer } from "../../di/container"; +import { BLOG_SYMBOLS } from "../../di/symbols"; +import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller"; +import { ArticleList as ArticleListClient } from "./article-list.client"; + +export async function ArticleList() { + const controller = blogContainer.get( + BLOG_SYMBOLS.IGetArticlesController, + ); + const articles = await controller({ status: "published", limit: 20 }); + const queryClient = getQueryClient(); + queryClient.setQueryData( + ["blog", "listArticles", { input: { status: "published", limit: 20 } }], + articles, + ); + + return ( + + + + ); +} +``` + +### Client component (internal) + +```typescript +// packages/blog/src/ui/components/article-list.client.tsx +"use client"; + +import { useArticleList } from "../hooks/use-article-list"; +import { ArticleCard } from "./article-card"; + +export function ArticleList() { + const { data: articles } = useArticleList(); + + if (articles.length === 0) { + return

No articles yet.

; + } + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ); +} +``` + +### Presentational component (receives props) + +```typescript +// packages/blog/src/ui/components/article-card.tsx +import type { Article } from "../../entities/models/article"; + +export type ArticleCardProps = { article: Article }; + +export function ArticleCard({ article }: ArticleCardProps) { + return ( + + ); +} +``` + +> **No `renderLink` props.** Client components are `"use client"` — +> functions cannot be passed from server components. Use plain `` tags +> or import the framework's Link component directly if the feature has +> that framework as a dependency. + +--- + +## Step 3: Export from the barrel + +The barrel exports **server components** under clean names. Client +components are internal — never re-exported: + +```typescript +// packages/blog/src/ui/index.ts +export { articleBySlugQuery, listArticlesQuery } from "./query"; +export { useArticleList } from "./hooks/use-article-list"; +export { useArticleBySlug } from "./hooks/use-article-by-slug"; +export { ArticleCard, type ArticleCardProps } from "./components/article-card"; +export { ArticleList } from "./components/article-list.server"; +export { ArticleDetail } from "./components/article-detail.server"; +``` + +Apps import from `@repo//ui` — they get the server component +which handles prefetch + hydration internally: + +```typescript +import { ArticleList } from "@repo/blog/ui"; +import { SiteHeader } from "@repo/navigation/ui"; +``` + +--- + +## App wiring + +### Next.js (`apps/web-next`) + +#### Root layout — DI + providers + +`bindAll()` runs once in the root layout. `NextTrpcProvider` wraps all +pages with the tRPC client and React Query. + +```typescript +// apps/web-next/src/app/layout.tsx +import { bindAll } from "../server/bind-production"; +import { Providers } from "./providers"; + +export default async function RootLayout({ children }) { + await bindAll(); + return ( + + {children} + + ); +} +``` + +```typescript +// apps/web-next/src/app/providers.tsx +"use client"; +import { NextTrpcProvider } from "@repo/core-trpc/next"; + +export function Providers({ children }) { + return {children}; +} +``` + +#### Pages — just import and render + +Feature server components handle prefetch + hydration internally. App +pages are thin — they import the component and pass route-derived props +(slug, id, etc.). No `appRouter`, no `queryClient`, no `HydrationBoundary` +in the app layer: + +```typescript +// apps/web-next/src/app/page.tsx +import { ArticleList } from "@repo/blog/ui"; + +export default function Home() { + return ; +} +``` + +```typescript +// apps/web-next/src/app/blog/[slug]/page.tsx +import { ArticleDetail } from "@repo/blog/ui"; + +export default async function BlogPostPage({ params }) { + const { slug } = await params; + return ; +} +``` + +The server component inside the feature resolves its controller from DI, +prefetches data, seeds the query cache, and wraps the client component +in `HydrationBoundary`. This gives: + +- Full HTML on first paint (SSR) +- Instant hydration (no loading flash) +- Background refetch on the client via `/api/trpc` + +> **Cache key format:** tRPC generates keys as +> `[routerName, procedureName, { input }]`. The `setQueryData` key in the +> server component must match what the client hook's `queryOptions` +> generates, or the client will re-fetch instead of hydrating. + +#### tRPC HTTP endpoint + +Client-side queries hit `/api/trpc` after hydration: + +```typescript +// apps/web-next/src/app/api/trpc/[trpc]/route.ts +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "@repo/core-api"; + +const handler = async (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => ({}), + }); + +export { handler as GET, handler as POST }; +``` + +### TanStack Start (`apps/web-tanstack`) + +Same pattern but with `TanstackTrpcProvider` from `@repo/core-trpc/tanstack` +in the root route, and TanStack Router loaders for server prefetch. + +--- + +## Tailwind CSS in the apps + +Both apps and Storybook need their own CSS entry point because Tailwind v4 +scans for utility classes only in files it knows about. Monorepo packages +live outside the app directory, so `@source` directives are required. + +```css +/* apps/web-next/src/styles/app.css */ +@import "tailwindcss"; +@source "../../../../packages/core-ui/src"; +@source "../../../../packages/navigation/src"; +@source "../../../../packages/blog/src"; +/* ... all feature packages with UI components */ + +@import "../../../../packages/core-ui/src/styles/theme.css"; +``` + +- **Next.js** uses `@tailwindcss/postcss` via `postcss.config.mjs` +- **Storybook** uses `@tailwindcss/vite` prepended in `viteFinal` +- **Theme tokens** live in `packages/core-ui/src/styles/theme.css` (single + source of truth). Both `globals.css` and app CSS files import it. + +--- + +## Seed data & DI binding + +### Dev seed (`USE_DEV_SEED=true` or default in development) + +Each feature has `src/__seeds__/dev.ts` that builds realistic mock data +using factories from `src/__factories__/`. The dev-seed binder +(`src/di/bind-dev-seed.ts`) populates the mock repository with this data. + +```typescript +// packages/blog/src/__seeds__/dev.ts +import { articleFactory } from "../__factories__/article.factory"; + +export function buildDevArticles(): Article[] { + return [ + articleFactory.build({ + slug: "hello-world", + title: "Hello World", + status: "published", + }), + articleFactory.build({ + slug: "second-post", + title: "Second Post", + status: "published", + }), + ]; +} +``` + +### Production (`USE_DEV_SEED=false` or `NODE_ENV=production`) + +Production binders (`src/di/bind-production.ts`) replace mock repositories +with Payload-backed implementations. They receive a `BindProductionContext` +with `config`, `tracer`, `logger`, `queue`. + +### Boot dispatcher + +`apps/web-next/src/server/bind-production.ts` picks the mode: + +| Condition | Mode | +| --------------------- | -------------------- | +| `USE_DEV_SEED=false` | Production (Payload) | +| `USE_DEV_SEED=true` | Dev seed (mocks) | +| `NODE_ENV=production` | Production | +| Default | Dev seed | + +Root `.env` is loaded globally via `dotenv-cli` wrapping Turbo +(`"dev": "dotenv -- turbo run dev"` in root `package.json`). + +### Adding a new use case to an existing feature + +1. Add the use case to `feature.manifest.ts` +2. Create input/output schemas in the use-case file +3. Write the use case factory + controller +4. Add the tRPC procedure to `integrations/api/router.ts` +5. Wire into both `bind-production.ts` and `bind-dev-seed.ts` +6. Add seed data to `__seeds__/dev.ts` if applicable +7. Create a hook in `src/ui/hooks/use-.ts` +8. Create client component in `src/ui/components/.client.tsx` +9. Create server component in `src/ui/components/.server.tsx` +10. Export the server component from `src/ui/index.ts` under the clean name + +--- + +## Cross-feature boundaries in UI + +- Features **may** import another feature's **root barrel** (types, schemas, + errors) but **not** its `./ui` subpath. UI composition across features + happens in the app layer. +- Navigation's `` receives `siteName`/`siteDescription` as + **props** — it does not import from `@repo/marketing-pages`. The app + page passes these scalars (the only case where the app fetches data + that crosses feature boundaries). +- If a page renders components from multiple features, the app page + imports and renders them side by side — each feature component handles + its own data fetching internally. + +--- + +## Checklist for new feature UI + +- [ ] Check `core-ui` for existing atoms/molecules before creating new primitives +- [ ] Hook in `src/ui/hooks/use-.ts` with `"use client"` + `useSuspenseQuery` +- [ ] Component(s) in `src/ui/components/` composing `core-ui` primitives (atoms -> molecules -> organisms) +- [ ] Barrel exports in `src/ui/index.ts` +- [ ] Server component (`.server.tsx`) with DI resolve + prefetch + `HydrationBoundary` +- [ ] Barrel exports server component under clean name (no `Server` suffix) +- [ ] `@repo/core-trpc`, `@tanstack/react-query`, `@trpc/client`, `react` in `package.json` +- [ ] App page just imports and renders: `` or `` +- [ ] `@source` directive in app CSS for the feature package (if it has Tailwind classes) +- [ ] Seed data in `__seeds__/dev.ts` (for dev mode) +- [ ] Both `bind-production.ts` and `bind-dev-seed.ts` wire the new use case