diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4f30e..9633cb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - name: Test run: pnpm test:once + - name: Check "use client" directives + working-directory: packages/ui + run: npx tsx scripts/check-use-client.ts + - name: Verify stories working-directory: packages/ui run: | diff --git a/packages/ui/package.json b/packages/ui/package.json index 3eaedd2..4991cff 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -87,7 +87,8 @@ "storybook:generate-docs": "tsx scripts/generate-docs.ts", "storybook:verify": "tsx scripts/verify-stories.ts", "storybook:fix-stories": "tsx scripts/fix-stories.ts", - "check:stories": "tsx scripts/check-story-coverage.ts" + "check:stories": "tsx scripts/check-story-coverage.ts", + "check:use-client": "tsx scripts/check-use-client.ts" }, "peerDependencies": { "next": ">=14.0.0", diff --git a/packages/ui/scripts/check-use-client.ts b/packages/ui/scripts/check-use-client.ts new file mode 100644 index 0000000..544e877 --- /dev/null +++ b/packages/ui/scripts/check-use-client.ts @@ -0,0 +1,183 @@ +/** + * 'use client' Directive Check + * + * Verifies every component source file that uses React hooks starts with a + * `'use client'` directive. Components without the directive crash Next.js + * App Router consumers at SSR time (React Server Components cannot use hooks). + * + * Scans `src/components/**\/*.tsx` (excluding stories/tests/visual stubs) and + * fails with exit code 1 if any file references a React hook without the + * directive in its prologue (before any imports). + * + * Usage: pnpm -F @vllnt/ui check:use-client + */ + +import { readFileSync, readdirSync, statSync } from 'fs' +import { dirname, join, relative } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PACKAGE_ROOT = join(__dirname, '..') +const COMPONENTS_DIR = join(PACKAGE_ROOT, 'src/components') + +const REACT_HOOK_PATTERN = /\buse[A-Z][A-Za-z0-9_]*\s*\(/ + +const USE_CLIENT_PATTERN = /^['"]use client['"];?\s*$/ + +const EXCLUDED_SUFFIXES = [ + '.stories.tsx', + '.test.tsx', + '.visual.tsx', + '.spec.tsx', +] + +function collectTsxFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry) + const entryStat = statSync(fullPath) + if (entryStat.isDirectory()) { + collectTsxFiles(fullPath, acc) + continue + } + if (!entry.endsWith('.tsx')) continue + if (EXCLUDED_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue + acc.push(fullPath) + } + return acc +} + +/** + * Removes string literals, block comments, and line comments from source so + * that hook-like text inside those constructs does not trigger false positives. + * Order: strings first (prevents `"/* ..."` from being eaten by block-comment + * regex), then block comments, then line comments. + */ +export function stripNonCode(source: string): string { + let result = source + result = result.replace(/`(?:[^`\\]|\\.)*`/g, '``') + result = result.replace(/"(?:[^"\\]|\\.)*"/g, '""') + result = result.replace(/'(?:[^'\\]|\\.)*'/g, "''") + result = result.replace(/\/\*[\s\S]*?\*\//g, (m) => + m.replace(/[^\n]/g, ' '), + ) + result = result.replace(/\/\/.*/g, '') + return result +} + +/** + * Matches lines that *start* a custom hook definition (function declaration, + * arrow function, or function expression) so their bodies can be skipped. + * Deliberately excludes `const useX = someHookCall(...)` assignments where + * the RHS is a call expression rather than a function literal. + */ +const HOOK_DEF_LINE_PATTERN = + /(?:function\s+use[A-Z][A-Za-z0-9_]*|(?:const|let|var)\s+use[A-Z][A-Za-z0-9_]*\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>|(?:const|let|var)\s+use[A-Z][A-Za-z0-9_]*\s*=\s*(?:async\s+)?function)/ + +/** + * Returns true when the source file contains actual React hook *calls*, + * excluding hook function/variable *definitions* and text in + * comments or string literals. + */ +export function fileUsesHooks(source: string): boolean { + const stripped = stripNonCode(source) + let depth = 0 + let hookBodyDepth = -1 + let pendingHookDef = false + + for (const line of stripped.split(/\r?\n/)) { + const opens = (line.match(/\{/g) ?? []).length + const closes = (line.match(/\}/g) ?? []).length + + if (hookBodyDepth >= 0) { + depth += opens - closes + if (depth <= hookBodyDepth) hookBodyDepth = -1 + continue + } + + if (pendingHookDef) { + if (opens > closes) { + hookBodyDepth = depth + pendingHookDef = false + depth += opens - closes + } else { + depth += opens - closes + } + continue + } + + if (HOOK_DEF_LINE_PATTERN.test(line)) { + if (opens > closes) { + hookBodyDepth = depth + } else { + pendingHookDef = true + } + depth += opens - closes + continue + } + + depth += opens - closes + const defMatch = /(?:function|const|let|var)\s+use[A-Z][A-Za-z0-9_]*/.exec(line) + const checkLine = defMatch ? line.slice(defMatch.index + defMatch[0].length) : line + if (REACT_HOOK_PATTERN.test(checkLine)) return true + } + + return false +} + +/** + * Returns true when `'use client'` appears in the directive prologue — i.e. + * before any import or non-comment code. Leading blank lines, `//` comments, + * and `/* … *\/` block comments (including those whose body lines lack a + * leading `*`) are skipped. + */ +export function hasUseClientDirective(source: string): boolean { + let inBlockComment = false + for (const raw of source.split(/\r?\n/)) { + const trimmed = raw.trim() + if (trimmed.length === 0) continue + if (inBlockComment) { + if (trimmed.includes('*/')) inBlockComment = false + continue + } + if (trimmed.startsWith('//')) continue + if (trimmed.startsWith('/*')) { + if (!trimmed.includes('*/')) inBlockComment = true + continue + } + return USE_CLIENT_PATTERN.test(trimmed) + } + return false +} + +function main(): void { + const files = collectTsxFiles(COMPONENTS_DIR) + const missing: string[] = [] + + for (const file of files) { + const source = readFileSync(file, 'utf8') + if (!fileUsesHooks(source)) continue + if (hasUseClientDirective(source)) continue + missing.push(relative(PACKAGE_ROOT, file)) + } + + if (missing.length > 0) { + console.error( + `Missing "use client" directive in ${missing.length} component(s) that use React hooks:\n`, + ) + for (const path of missing) { + console.error(` - ${path}`) + } + console.error( + '\nAdd `"use client";` as the first line of each file. See issue #137.', + ) + process.exit(1) + } + + console.log( + `OK: all ${files.length} component file(s) with React hooks declare "use client".`, + ) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} diff --git a/packages/ui/src/components/client-directives.test.ts b/packages/ui/src/components/client-directives.test.ts index 04e7b93..65f3ab0 100644 --- a/packages/ui/src/components/client-directives.test.ts +++ b/packages/ui/src/components/client-directives.test.ts @@ -3,25 +3,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { + fileUsesHooks, + hasUseClientDirective, + stripNonCode, +} from "../../scripts/check-use-client"; + const COMPONENTS_ROOT = join(__dirname); -const CLIENT_HOOKS = [ - "useActionState", - "useCallback", - "useContext", - "useDeferredValue", - "useEffect", - "useId", - "useImperativeHandle", - "useInsertionEffect", - "useLayoutEffect", - "useMemo", - "useOptimistic", - "useReducer", - "useRef", - "useState", - "useSyncExternalStore", - "useTransition", -] as const; const SKIPPED_SUFFIXES = [".stories.tsx", ".test.tsx", ".visual.tsx"] as const; function listTypeScriptFiles(directory: string): string[] { @@ -37,23 +25,272 @@ function listTypeScriptFiles(directory: string): string[] { }); } -function usesClientHooks(source: string): boolean { - return CLIENT_HOOKS.some((hook) => - new RegExp(`\\b${hook}\\b`, "u").test(source), - ); +describe("stripNonCode", () => { + it("removes single-line comments", () => { + expect(stripNonCode("// useTheme()")).not.toMatch(/useTheme/); + }); + + it("removes block comments (JSDoc)", () => { + expect(stripNonCode("/** @uses useTheme() */")).not.toMatch(/useTheme/); + }); + + it("removes double-quoted string literals", () => { + expect(stripNonCode('"useTheme()"')).not.toMatch(/useTheme/); + }); + + it("removes single-quoted string literals", () => { + expect(stripNonCode("'useTheme()'")).not.toMatch(/useTheme/); + }); + + it("removes template literals", () => { + expect(stripNonCode("`call useTheme() here`")).not.toMatch(/useTheme/); + }); + + it("preserves actual hook call code", () => { + expect(stripNonCode("const theme = useTheme()")).toMatch(/useTheme/); + }); + + it("preserves newline structure (does not collapse lines)", () => { + const result = stripNonCode("a\n/* comment */\nb"); + expect(result.split("\n")).toHaveLength(3); + }); +}); + +describe("fileUsesHooks", () => { + describe("true positives — hook calls that must be detected", () => { + it("detects useState", () => { + expect( + fileUsesHooks(` +"use client"; +import { useState } from 'react'; +export function Comp() { const [x] = useState(0); return null; } +`), + ).toBe(true); + }); + + it("detects useRef", () => { + expect(fileUsesHooks("const r = useRef(null)")).toBe(true); + }); + + it("detects custom hook call useTheme()", () => { + expect( + fileUsesHooks(` +"use client"; +export function Comp() { const t = useTheme(); return null; } +`), + ).toBe(true); + }); + + it("detects usePathname from next/navigation", () => { + expect( + fileUsesHooks(` +"use client"; +import { usePathname } from 'next/navigation'; +export function Nav() { const p = usePathname(); return null; } +`), + ).toBe(true); + }); + + it("detects useHorizontalScroll custom hook call", () => { + expect( + fileUsesHooks(` +"use client"; +const { ref } = useHorizontalScroll(); +`), + ).toBe(true); + }); + + it("detects hook call in a one-liner hook variable definition (const useX = useY(...))", () => { + expect(fileUsesHooks("const useData = useQuery(someArg)")).toBe(true); + }); + }); + + describe("false positives — must NOT be flagged", () => { + it("ignores hook function definition: export function useFoo(", () => { + expect( + fileUsesHooks(` +export function useFormatter(value: number) { + return value.toFixed(2); } +`), + ).toBe(false); + }); + + it("ignores hook const arrow definition: const useFoo = (", () => { + expect( + fileUsesHooks(` +const useLocalStore = () => { + return {}; +}; +`), + ).toBe(false); + }); -function hasUseClientDirective(source: string): boolean { - const firstNonEmptyLine = source - .split(/\r?\n/u) - .find((line) => line.trim().length > 0) - ?.trim(); + it("ignores multi-line function hook definition body calling useState", () => { + expect( + fileUsesHooks(` +export function useCounter() { + const [count] = useState(0); + return { count }; +} +`), + ).toBe(false); + }); - return ( - firstNonEmptyLine === '"use client";' || - firstNonEmptyLine === "'use client';" - ); + it("ignores hook definitions whose opening brace appears on a later line", () => { + expect( + fileUsesHooks(` +export function useCounter( + initialValue: number, + step = 1, +) +{ + const [count] = useState(initialValue); + return { count, step }; } +`), + ).toBe(false); + }); + + it("ignores arrow hook definition body calling useMemo and useRef", () => { + expect( + fileUsesHooks(` +const useLayout = () => { + const val = useMemo(() => 0, []); + const ref = useRef(null); + return { val, ref }; +}; +`), + ).toBe(false); + }); + + it("ignores hook name in single-line comment", () => { + expect(fileUsesHooks("// call useTheme() to get the theme")).toBe(false); + }); + + it("ignores hook name in block comment", () => { + expect(fileUsesHooks("/* useTheme() is called internally */")).toBe( + false, + ); + }); + + it("ignores hook name in JSDoc", () => { + expect( + fileUsesHooks(` +/** + * @example useTheme() + */ +export function Comp() { return null; } +`), + ).toBe(false); + }); + + it("ignores hook name in double-quoted string", () => { + expect(fileUsesHooks('const label = "useTheme()"')).toBe(false); + }); + + it("ignores hook name in single-quoted string", () => { + expect(fileUsesHooks("const label = 'useTheme()'")).toBe(false); + }); + + it("ignores hook name in template literal", () => { + expect(fileUsesHooks("const msg = `call useTheme() for theming`")).toBe( + false, + ); + }); + + it("returns false for a file with no hooks at all", () => { + expect( + fileUsesHooks(` +import { cn } from '../../lib/utils'; +export function Badge({ className }: { className?: string }) { + return ; +} +`), + ).toBe(false); + }); + }); +}); + +describe("hasUseClientDirective", () => { + describe("true positives — directive present", () => { + it("detects single-quoted directive as first line", () => { + expect( + hasUseClientDirective("'use client';\nimport React from 'react'"), + ).toBe(true); + }); + + it("detects double-quoted directive as first line", () => { + expect( + hasUseClientDirective('"use client";\nimport React from "react"'), + ).toBe(true); + }); + + it("detects directive after a leading blank line", () => { + expect( + hasUseClientDirective("\n'use client';\nimport React from 'react'"), + ).toBe(true); + }); + + it("detects directive after a single-line comment", () => { + expect( + hasUseClientDirective( + "// @license MIT\n'use client';\nimport React from 'react'", + ), + ).toBe(true); + }); + + it("detects directive after a multi-line block comment", () => { + expect( + hasUseClientDirective( + "/**\n * Module header.\n */\n'use client';\nimport React from 'react'", + ), + ).toBe(true); + }); + + it("detects directive after a block comment whose body lines have no leading *", () => { + expect( + hasUseClientDirective( + "/*\ngeneric non-starred comment body\n*/\n'use client';\nimport React from 'react'", + ), + ).toBe(true); + }); + + it("detects directive without trailing semicolon", () => { + expect( + hasUseClientDirective("'use client'\nimport React from 'react'"), + ).toBe(true); + }); + }); + + describe("false positives — directive absent or misplaced", () => { + it("returns false when no directive is present", () => { + expect( + hasUseClientDirective( + "import { useState } from 'react';\nexport function Comp() { return null; }", + ), + ).toBe(false); + }); + + it("returns false when directive appears after an import", () => { + expect( + hasUseClientDirective( + "import { useState } from 'react';\n'use client';", + ), + ).toBe(false); + }); + + it("returns false for an empty file", () => { + expect(hasUseClientDirective("")).toBe(false); + }); + + it("returns false for a file with only comments", () => { + expect(hasUseClientDirective("// just a comment\n/* another */")).toBe( + false, + ); + }); + }); +}); describe("client directives", () => { it("marks hook-based shipped components as client components", () => { @@ -63,7 +300,7 @@ describe("client directives", () => { ) .filter((filePath) => { const source = readFileSync(filePath, "utf8"); - return usesClientHooks(source) && !hasUseClientDirective(source); + return fileUsesHooks(source) && !hasUseClientDirective(source); }) .map((filePath) => filePath.replace(`${COMPONENTS_ROOT}/`, "components/"),