From d5fd38903249ec629a74b2f9256dc4b318bcdad3 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:59:58 +0200 Subject: [PATCH 1/5] fix(ui): add 'use client' to chart components using React hooks AreaChart and LineChart call useMemo/useState/useId but lacked the 'use client' directive, causing Next.js App Router consumers to crash with "useState is not a function" when the components are imported into a Server Component tree. Also add a CI audit script (scripts/check-use-client.ts) wired into the Quality Gates workflow that fails if any component source uses React hooks without declaring 'use client' on its first line, so this class of regression is caught before publishing. Fixes #137 --- .github/workflows/ci.yml | 4 + packages/ui/package.json | 3 +- packages/ui/scripts/check-use-client.ts | 88 +++++++++++++++++++ .../ui/src/components/chart/area-chart.tsx | 2 + .../ui/src/components/chart/line-chart.tsx | 2 + 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/ui/scripts/check-use-client.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0113030..95b8c85 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 f31c157..ac2877f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -85,7 +85,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..4584f3f --- /dev/null +++ b/packages/ui/scripts/check-use-client.ts @@ -0,0 +1,88 @@ +/** + * '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 on its first non-empty line. + * + * 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 = + /\b(?:useState|useEffect|useRef|useLayoutEffect|useCallback|useMemo|useReducer|useContext|useImperativeHandle|useId|useTransition|useDeferredValue|useSyncExternalStore|useInsertionEffect)\b/ + +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 +} + +function firstCodeLine(source: string): string { + for (const raw of source.split(/\r?\n/)) { + const trimmed = raw.trim() + if (trimmed.length === 0) continue + return trimmed + } + return '' +} + +function main(): void { + const files = collectTsxFiles(COMPONENTS_DIR) + const missing: string[] = [] + + for (const file of files) { + const source = readFileSync(file, 'utf8') + if (!REACT_HOOK_PATTERN.test(source)) continue + if (USE_CLIENT_PATTERN.test(firstCodeLine(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".`, + ) +} + +main() diff --git a/packages/ui/src/components/chart/area-chart.tsx b/packages/ui/src/components/chart/area-chart.tsx index 77ee914..be6cea6 100644 --- a/packages/ui/src/components/chart/area-chart.tsx +++ b/packages/ui/src/components/chart/area-chart.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { cn } from "../../lib/utils"; diff --git a/packages/ui/src/components/chart/line-chart.tsx b/packages/ui/src/components/chart/line-chart.tsx index 3f6a245..357a818 100644 --- a/packages/ui/src/components/chart/line-chart.tsx +++ b/packages/ui/src/components/chart/line-chart.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { cn } from "../../lib/utils"; From 658f036031c8df419506d50d01ad2014e038497b Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:50:27 +0200 Subject: [PATCH 2/5] fix(ui): broaden use-client guard coverage --- packages/ui/scripts/check-use-client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/scripts/check-use-client.ts b/packages/ui/scripts/check-use-client.ts index 4584f3f..ff7e485 100644 --- a/packages/ui/scripts/check-use-client.ts +++ b/packages/ui/scripts/check-use-client.ts @@ -20,8 +20,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const PACKAGE_ROOT = join(__dirname, '..') const COMPONENTS_DIR = join(PACKAGE_ROOT, 'src/components') -const REACT_HOOK_PATTERN = - /\b(?:useState|useEffect|useRef|useLayoutEffect|useCallback|useMemo|useReducer|useContext|useImperativeHandle|useId|useTransition|useDeferredValue|useSyncExternalStore|useInsertionEffect)\b/ +const REACT_HOOK_PATTERN = /\buse[A-Z][A-Za-z0-9_]*\s*\(/; const USE_CLIENT_PATTERN = /^['"]use client['"];?\s*$/ From 8f1607d55bd935bd44254f38202752c83fcfedab Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:41:59 +0200 Subject: [PATCH 3/5] fix(ui): harden use-client directive audit --- packages/ui/scripts/check-use-client.ts | 67 ++++- .../src/components/client-directives.test.ts | 236 ++++++++++++++++++ 2 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/components/client-directives.test.ts diff --git a/packages/ui/scripts/check-use-client.ts b/packages/ui/scripts/check-use-client.ts index ff7e485..8133292 100644 --- a/packages/ui/scripts/check-use-client.ts +++ b/packages/ui/scripts/check-use-client.ts @@ -7,7 +7,7 @@ * * 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 on its first non-empty line. + * directive in its prologue (before any imports). * * Usage: pnpm -F @vllnt/ui check:use-client */ @@ -20,7 +20,7 @@ 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 REACT_HOOK_PATTERN = /\buse[A-Z][A-Za-z0-9_]*\s*\(/ const USE_CLIENT_PATTERN = /^['"]use client['"];?\s*$/ @@ -46,13 +46,62 @@ function collectTsxFiles(dir: string, acc: string[] = []): string[] { return acc } -function firstCodeLine(source: string): string { +/** + * 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 +} + +/** + * 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) + for (const line of stripped.split(/\r?\n/)) { + 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 - return trimmed + 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 '' + return false } function main(): void { @@ -61,8 +110,8 @@ function main(): void { for (const file of files) { const source = readFileSync(file, 'utf8') - if (!REACT_HOOK_PATTERN.test(source)) continue - if (USE_CLIENT_PATTERN.test(firstCodeLine(source))) continue + if (!fileUsesHooks(source)) continue + if (hasUseClientDirective(source)) continue missing.push(relative(PACKAGE_ROOT, file)) } @@ -84,4 +133,6 @@ function main(): void { ) } -main() +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 new file mode 100644 index 0000000..86792f1 --- /dev/null +++ b/packages/ui/src/components/client-directives.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; + +import { + fileUsesHooks, + hasUseClientDirective, + stripNonCode, +} from "../../scripts/check-use-client"; + +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); + }); + + 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, + ); + }); + }); +}); From 882d70bfa55bc26b48df9958cf36b3e1baeb0243 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:40:27 +0200 Subject: [PATCH 4/5] fix(ui): ignore custom hook definitions in use-client audit --- packages/ui/scripts/check-use-client.ts | 29 +++++++++++++++++++ .../src/components/client-directives.test.ts | 23 +++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/ui/scripts/check-use-client.ts b/packages/ui/scripts/check-use-client.ts index 8133292..a584063 100644 --- a/packages/ui/scripts/check-use-client.ts +++ b/packages/ui/scripts/check-use-client.ts @@ -64,6 +64,15 @@ export function stripNonCode(source: string): string { 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 @@ -71,11 +80,31 @@ export function stripNonCode(source: string): string { */ export function fileUsesHooks(source: string): boolean { const stripped = stripNonCode(source) + let depth = 0 + let hookBodyDepth = -1 + 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 (HOOK_DEF_LINE_PATTERN.test(line)) { + if (opens > closes) hookBodyDepth = depth + 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 } diff --git a/packages/ui/src/components/client-directives.test.ts b/packages/ui/src/components/client-directives.test.ts index 0eb4f64..cd93a8a 100644 --- a/packages/ui/src/components/client-directives.test.ts +++ b/packages/ui/src/components/client-directives.test.ts @@ -126,6 +126,29 @@ const useLocalStore = () => { ).toBe(false); }); + it("ignores multi-line function hook definition body calling useState", () => { + expect( + fileUsesHooks(` +export function useCounter() { + const [count] = useState(0); + return { count }; +} +`), + ).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); }); From 70f5d1128e8414c7b792bab4d0fa69fbbb90cefc Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:59:31 +0200 Subject: [PATCH 5/5] fix(ui): handle deferred hook-definition braces in use-client audit --- packages/ui/scripts/check-use-client.ts | 18 +++++++++++++++++- .../src/components/client-directives.test.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/ui/scripts/check-use-client.ts b/packages/ui/scripts/check-use-client.ts index a584063..544e877 100644 --- a/packages/ui/scripts/check-use-client.ts +++ b/packages/ui/scripts/check-use-client.ts @@ -82,6 +82,7 @@ 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 @@ -93,8 +94,23 @@ export function fileUsesHooks(source: string): boolean { 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 + if (opens > closes) { + hookBodyDepth = depth + } else { + pendingHookDef = true + } depth += opens - closes continue } diff --git a/packages/ui/src/components/client-directives.test.ts b/packages/ui/src/components/client-directives.test.ts index cd93a8a..65f3ab0 100644 --- a/packages/ui/src/components/client-directives.test.ts +++ b/packages/ui/src/components/client-directives.test.ts @@ -137,6 +137,21 @@ export function useCounter() { ).toBe(false); }); + 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(`