Skip to content

Commit e6ef9ca

Browse files
committed
feat: ship "use client" on stateful components
Component modules that call a hook, create a context, or import a Radix dialog, dropdown-menu or tooltip, or sonner, start with the directive; SWC keeps it in dist. dep-guard fails when a component's directive does not match what it does.
1 parent c30a08c commit e6ef9ca

51 files changed

Lines changed: 141 additions & 10 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ARCHITECTURE.md‎

Lines changed: 5 additions & 4 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 1 deletion

‎README.md‎

Lines changed: 1 addition & 1 deletion

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"access": "public"
2828
},
2929
"scripts": {
30-
"build": "bun run clean && bun run generate && bun run build:js && bun run build:types && bun run build:css && bun run contrast-test",
30+
"build": "bun run clean && bun run generate && bun run build:js && bun run build:types && bun run build:css && bun run contrast-test && bun run dep-guard",
3131
"clean": "rm -rf dist",
3232
"generate": "node scripts/generate-exports.mjs",
3333
"build:js": "swc src -d dist --strip-leading-paths --extensions .ts,.tsx --ignore \"**/*.test.ts\" --ignore \"**/*.test.tsx\"",

‎scripts/dep-guard.mjs‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Two import rules that types alone cannot enforce. Each one is a property a
1+
// Three source rules that types alone cannot enforce. Each one is a property a
22
// consumer would feel and no other step in the build would notice.
33
//
44
// 1. Nothing imports from @workbench/*. corbits-ui is a clean rewrite; a single
@@ -12,7 +12,13 @@
1212
// Only Node and Vite surface it; a Turbopack consumer builds cleanly, so
1313
// this cannot be left to whichever bundler the person testing happened to
1414
// use.
15-
import { readdirSync, readFileSync, statSync } from "node:fs";
15+
//
16+
// 3. A `ui/` or `blocks/` component starts with "use client" exactly when it
17+
// calls a hook, creates a context, defines an inline event handler, or
18+
// imports an optional peer. Missing, it breaks React Server Components
19+
// consumers; extra, it makes a stateless component client-only for no
20+
// reason. After a build, the emitted dist/ module must keep the directive.
21+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1622
import { dirname, join, relative, resolve } from "node:path";
1723

1824
const ROOT = new URL("../src", import.meta.url).pathname;
@@ -85,11 +91,37 @@ for (const path of reachableFrom(barrel)) {
8591
}
8692
}
8793

94+
// --- Rule 3: client boundaries ------------------------------------------------
95+
96+
const CLIENT_ONLY = new RegExp(
97+
[
98+
String.raw`\buse[A-Z]\w*\s*[<(]`,
99+
"createContext",
100+
String.raw`\bon[A-Z]\w*=\{\s*(\(|\w+\s*=>)`,
101+
`["'](${OPTIONAL_PEERS.map((peer) => peer.replace("/", "\\/")).join("|")})["']`,
102+
].join("|"),
103+
);
104+
const DIST = new URL("../dist", import.meta.url).pathname;
105+
const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, "$1");
106+
107+
for (const path of sources) {
108+
if (!/^(ui|blocks)\//.test(id(path)) || /\.test\.tsx?$/.test(path)) continue;
109+
const source = read(path);
110+
const marked = source.startsWith('"use client";');
111+
if (CLIENT_ONLY.test(stripComments(source)) !== marked) {
112+
violations.push(`${id(path)}: ${marked ? "stateless but marked" : "stateful but missing"} "use client"`);
113+
}
114+
const built = join(DIST, id(path).replace(/\.tsx?$/, ".js"));
115+
if (marked && existsSync(built) && !read(built).startsWith('"use client";')) {
116+
violations.push(`${id(path)}: the build dropped "use client" from ${relative(DIST, built)}`);
117+
}
118+
}
119+
88120
if (violations.length > 0) {
89121
console.error(`dep-guard: forbidden imports\n${[...new Set(violations)].join("\n")}`);
90122
process.exit(1);
91123
}
92124
console.log(
93125
`dep-guard: clean (${RULES.map((rule) => rule.label).join(", ")}; ` +
94-
`root barrel free of ${OPTIONAL_PEERS.join(", ")})`,
126+
`root barrel free of ${OPTIONAL_PEERS.join(", ")}; client boundaries match)`,
95127
);

‎src/blocks/login/login-form.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { useState } from "react";
24
import type { ReactNode } from "react";
35

‎src/ui/animated-number.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { useEffect, useRef, useState } from "react";
24

35
import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";

‎src/ui/approval-card.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { useState } from "react";
24
import type { ReactNode } from "react";
35

‎src/ui/boot-screen.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { useId } from "react";
24
import type { ReactNode } from "react";
35

0 commit comments

Comments
 (0)