Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions corpus/frontend/react/index.yaml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions corpus/frontend/react/rsc-default.yaml
Original file line number Diff line number Diff line change
@@ -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 <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

// ✅ Client only when needed
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
antiPattern: |
// ❌ 'use client' on a component that just renders data
"use client";
export default function ProductCard({ product }) {
return <div>{product.name}</div>; // 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
27 changes: 27 additions & 0 deletions corpus/frontend/react/state-hierarchy.yaml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions corpus/frontend/react/suspense-boundary.yaml
Original file line number Diff line number Diff line change
@@ -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 (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<Skeleton />}>
<AsyncDataComponent />
</Suspense>
</ErrorBoundary>
);
}
tips:
- Skeleton over spinner for layout-stable loading
- ErrorBoundary wraps Suspense
33 changes: 33 additions & 0 deletions corpus/frontend/react/zustand-store.yaml
Original file line number Diff line number Diff line change
@@ -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<AuthStore>()(
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
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
105 changes: 103 additions & 2 deletions src/plugins/react/tools/get-pattern.ts
Original file line number Diff line number Diff line change
@@ -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<string, { file?: string }>;
};

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<string, LoadedCorpusPattern | null>();

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(
Expand All @@ -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 {
Expand All @@ -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 }] };
}
);
Expand Down
49 changes: 49 additions & 0 deletions tests/react-pattern-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -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("<ErrorBoundary fallback={<ErrorFallback />}>");
});

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");
});
Loading