diff --git a/corpus/frontend/react/index.yaml b/corpus/frontend/react/index.yaml new file mode 100644 index 0000000..96665ea --- /dev/null +++ b/corpus/frontend/react/index.yaml @@ -0,0 +1,10 @@ +namespace: frontend.react +patterns: + rsc-default: + file: rsc-default.yaml + zustand-store: + file: zustand-store.yaml + state-hierarchy: + file: state-hierarchy.yaml + suspense-boundary: + file: suspense-boundary.yaml diff --git a/corpus/frontend/react/rsc-default.yaml b/corpus/frontend/react/rsc-default.yaml new file mode 100644 index 0000000..cee7eea --- /dev/null +++ b/corpus/frontend/react/rsc-default.yaml @@ -0,0 +1,28 @@ +name: rsc-default +category: rendering +description: Server Components are the default. Use 'use client' only when interactivity is required. +when: Every new component. Decide server vs client first. +code: | + // ✅ RSC by default - no directive needed + export default async function ProductList() { + const products = await db.query("SELECT * FROM products"); + return ; + } + + // ✅ Client only when needed + "use client"; + import { useState } from "react"; + export function Counter() { + const [count, setCount] = useState(0); + return ; + } +antiPattern: | + // ❌ 'use client' on a component that just renders data + "use client"; + export default function ProductCard({ product }) { + return
{product.name}
; // no interactivity - RSC is fine + } +tips: + - SEO-critical content → RSC/SSR/SSG/ISR + - Non-SEO + interactive → Client Component + - Fetching data → always RSC unless client-side only diff --git a/corpus/frontend/react/state-hierarchy.yaml b/corpus/frontend/react/state-hierarchy.yaml new file mode 100644 index 0000000..089e079 --- /dev/null +++ b/corpus/frontend/react/state-hierarchy.yaml @@ -0,0 +1,27 @@ +name: state-hierarchy +category: state +description: "Strict state placement order: URL → server → local → Zustand → Context (injection only)." +when: Deciding where to put any new piece of state. +code: | + // 1. URL state (searchParams) - shareable, bookmarkable + const searchParams = useSearchParams(); + const sort = searchParams.get("sort") ?? "asc"; + + // 2. Server state - React Query / SWR / RSC fetch + const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers }); + + // 3. Local component state + const [open, setOpen] = useState(false); + + // 4. Shared client state - Zustand + const user = useAuthStore((s) => s.user); + + // 5. Context - injection only (theme, auth, i18n, feature flags) + const theme = useContext(ThemeContext); +antiPattern: | + // ❌ Context for frequently changing state + const CountContext = createContext(0); // re-renders all consumers on every change +tips: + - "Ask: can this live in the URL? If yes → URL state" + - Context is for injection (stable values), not reactive state + - Redux is banned - Zustand only for shared client state diff --git a/corpus/frontend/react/suspense-boundary.yaml b/corpus/frontend/react/suspense-boundary.yaml new file mode 100644 index 0000000..56e2105 --- /dev/null +++ b/corpus/frontend/react/suspense-boundary.yaml @@ -0,0 +1,20 @@ +name: suspense-boundary +category: rendering +description: Suspense for async RSC children. Error boundaries for error states. +when: Any page with async data in RSC children. +code: | + import { Suspense } from "react"; + import { ErrorBoundary } from "react-error-boundary"; + + export default function Page() { + return ( + }> + }> + + + + ); + } +tips: + - Skeleton over spinner for layout-stable loading + - ErrorBoundary wraps Suspense diff --git a/corpus/frontend/react/zustand-store.yaml b/corpus/frontend/react/zustand-store.yaml new file mode 100644 index 0000000..7fd8507 --- /dev/null +++ b/corpus/frontend/react/zustand-store.yaml @@ -0,0 +1,33 @@ +name: zustand-store +category: state +description: Zustand for shared client state. Slice pattern for large stores. +when: State shared across multiple client components that isn't URL or server state. +code: | + import { create } from "zustand"; + import { persist } from "zustand/middleware"; + + interface AuthStore { + user: User | null; + token: string | null; + setUser: (user: User, token: string) => void; + logout: () => void; + } + + export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + setUser: (user, token) => set({ user, token }), + logout: () => set({ user: null, token: null }), + }), + { name: "auth-storage" } + ) + ); + + // Usage - subscribe only to needed slice (perf) + const user = useAuthStore((s) => s.user); +tips: + - Never use Redux + - Slice selectors prevent unnecessary re-renders + - persist middleware for auth/settings diff --git a/corpus/index.yaml b/corpus/index.yaml index 91bffc9..479274f 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -13,3 +13,5 @@ namespaces: index: frontend/shadcn/index.yaml frontend.ui-ux: index: frontend/ui-ux/index.yaml + frontend.react: + index: frontend/react/index.yaml diff --git a/src/plugins/react/tools/get-pattern.ts b/src/plugins/react/tools/get-pattern.ts index 8975cc9..a953bad 100644 --- a/src/plugins/react/tools/get-pattern.ts +++ b/src/plugins/react/tools/get-pattern.ts @@ -1,6 +1,102 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; -import { PATTERNS, getPatternByName } from "../data.js"; +import { PATTERNS, getPatternByName, type Pattern } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "frontend.react"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + patterns?: Record; +}; + +type CorpusPatternEntry = { + name?: string; + category?: string; + description?: string; + when?: string; + code?: string; + antiPattern?: string; + tips?: string[]; +}; + +type LoadedCorpusPattern = { pattern: Pattern; source: string }; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.react"; + +const cachedCorpusPatterns = new Map(); + +function normalize(name: string): string { + return name.toLowerCase().trim(); +} + +function loadCorpusPattern(name: string): LoadedCorpusPattern | null { + const key = normalize(name); + if (cachedCorpusPatterns.has(key)) { + return cachedCorpusPatterns.get(key) ?? null; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusPatterns.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const patternPath = namespaceIndex?.patterns?.[key]?.file; + if (!patternPath) { + cachedCorpusPatterns.set(key, null); + return null; + } + + const raw = readFileSync(join(corpusRoot, "frontend/react", patternPath), "utf8"); + const entry = YAML.parse(raw) as CorpusPatternEntry | null; + if ( + !entry || + normalize(entry.name ?? "") !== key || + !entry.category || + !entry.description || + !entry.when || + !entry.code + ) { + cachedCorpusPatterns.set(key, null); + return null; + } + + const loaded: LoadedCorpusPattern = { + pattern: { + name: entry.name ?? name, + category: entry.category as Pattern["category"], + description: entry.description, + when: entry.when, + code: entry.code, + antiPattern: entry.antiPattern, + tips: entry.tips, + }, + source: corpusNamespace, + }; + cachedCorpusPatterns.set(key, loaded); + return loaded; + } catch { + cachedCorpusPatterns.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -10,7 +106,8 @@ export function register(server: McpServer): void { name: z.string().describe("Pattern name (e.g. 'rsc-default', 'state-hierarchy', 'zustand-store', 'suspense-boundary', 'nextjs-metadata', 'composition-pattern', 'component-template')"), }, async ({ name }) => { - const pattern = getPatternByName(name); + const corpusEntry = loadCorpusPattern(name); + const pattern = corpusEntry?.pattern ?? getPatternByName(name); if (!pattern) { const available = PATTERNS.map((p) => p.name).join(", "); return { @@ -33,6 +130,10 @@ export function register(server: McpServer): void { for (const tip of pattern.tips) text += `- ${tip}\n`; } + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text", text }] }; } ); diff --git a/tests/react-pattern-corpus-backed-tools-behaviour.test.ts b/tests/react-pattern-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..a5d8554 --- /dev/null +++ b/tests/react-pattern-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerReactGetPattern } from "../src/plugins/react/tools/get-pattern.ts"; + +const reactGetPattern = captureTool(registerReactGetPattern); + +test("react_get_pattern prefers corpus metadata for rsc-default", async () => { + const result = await reactGetPattern.invoke({ name: "rsc-default" }); + const text = extractTextContent(result); + + expect(text).toContain("# rsc-default [rendering]"); + expect(text).toContain("**Corpus Source:** frontend.react"); + expect(text).toContain("export default async function ProductList()"); +}); + +test("react_get_pattern prefers corpus metadata for zustand-store", async () => { + const result = await reactGetPattern.invoke({ name: "zustand-store" }); + const text = extractTextContent(result); + + expect(text).toContain("# zustand-store [state]"); + expect(text).toContain("**Corpus Source:** frontend.react"); + expect(text).toContain("create<"); +}); + +test("react_get_pattern prefers corpus metadata for state-hierarchy", async () => { + const result = await reactGetPattern.invoke({ name: "state-hierarchy" }); + const text = extractTextContent(result); + + expect(text).toContain("# state-hierarchy [state]"); + expect(text).toContain("**Corpus Source:** frontend.react"); + expect(text).toContain("useSearchParams()"); +}); + +test("react_get_pattern prefers corpus metadata for suspense-boundary", async () => { + const result = await reactGetPattern.invoke({ name: "suspense-boundary" }); + const text = extractTextContent(result); + + expect(text).toContain("# suspense-boundary [rendering]"); + expect(text).toContain("**Corpus Source:** frontend.react"); + expect(text).toContain("}>"); +}); + +test("react_get_pattern falls back to in-file data for non-corpus patterns", async () => { + const result = await reactGetPattern.invoke({ name: "composition-pattern" }); + const text = extractTextContent(result); + + expect(text).toContain("# composition-pattern [architecture]"); + expect(text).not.toContain("**Corpus Source:** frontend.react"); +});