The remaining "Hydration failed" error came from passing a Card (a <div>) as the asChild target of Radix's DialogTrigger. Radix's Slot then injects button-specific props (type="button", aria-haspopup, …) onto the underlying <div>, and React's SSR vs client trees diverge on those attributes. Same overlay pattern that already worked for the Pair card now applies to every Dialog-card-trigger in the app: - accounts list — Delete card per row - account detail — Unpair card - account detail — Delete card The visible Card stays a <div>. A real <button type="button"> with no children sits absolutely-positioned over the card surface and is the DialogTrigger target. Click area is identical, HTML is valid, no Radix prop-forwarding into the wrong element type. Also fixed: edit-account-form.tsx had the original <button>...<Card>...</Card></button> nesting (the new static guard caught it). Replaced with a Card that's its own pressable region (onClick + onKeyDown + role=button on the <div>; no nested button). Test guards ----------- + src/test/no-render-warnings.test.tsx (6 tests) Renders AccountsListView, ThemeToggle, EditMessageForm via renderToString and asserts neither console.error nor console.warn was invoked. Also scans the produced HTML for any <button> region that contains a <div>/<p>/<h*> — invalid nesting that would cause a hydration mismatch in the browser. + src/test/no-button-wrapping-card.test.ts (2 tests) Walks every production .tsx file in src/ and fails if any contains a literal `<button` (lowercase) that wraps `<Card`/`<CardContent`/ `<CardHeader`. Caught a real instance in edit-account-form.tsx that I missed in the earlier round. Total tests: 100. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
|
|
/**
|
|
* Static guard: no `.tsx` file may contain a literal `<button` element
|
|
* wrapping a `<Card`, `<CardContent`, or `<CardHeader` (the shadcn
|
|
* primitives that render `<div>`).
|
|
*
|
|
* That nesting is invalid HTML — `<button>`'s content model is phrasing
|
|
* content, and `<div>` is flow content. Browsers auto-close the button
|
|
* when they hit the inner div; React 19 SSR doesn't, and the resulting
|
|
* tree mismatch raised "Hydration failed" in production. We use Card
|
|
* directly as the trigger now (DialogTrigger asChild forwards the
|
|
* click) or place a transparent submit button as a sibling.
|
|
*
|
|
* We scan for the lowercase `<button` only — `<Button>` (shadcn's
|
|
* styled component) is fine; it just renders a real `<button>` with no
|
|
* children. `<button>` followed by `<Card>` within a few hundred
|
|
* characters is the smell.
|
|
*/
|
|
|
|
const SRC_ROOT = join(__dirname, "..");
|
|
|
|
function listTsxFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
const st = statSync(full);
|
|
if (st.isDirectory()) {
|
|
out.push(...listTsxFiles(full));
|
|
} else if (entry.endsWith(".tsx")) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface Hit {
|
|
file: string;
|
|
line: number;
|
|
excerpt: string;
|
|
}
|
|
|
|
function findHits(content: string): Array<{ line: number; excerpt: string }> {
|
|
const hits: Array<{ line: number; excerpt: string }> = [];
|
|
// For every opening `<button` (lowercase), grab everything up to its
|
|
// matching `</button>` and look for a Card primitive opener inside.
|
|
const cardOpener = /<(?:Card|CardContent|CardHeader)\b/;
|
|
let cursor = 0;
|
|
while (cursor < content.length) {
|
|
const open = content.indexOf("<button", cursor);
|
|
if (open === -1) break;
|
|
// Skip JSX comments, attribute-substring matches like `<buttonish`, etc.
|
|
const next = content.charAt(open + "<button".length);
|
|
if (next && /[a-zA-Z0-9]/.test(next)) {
|
|
cursor = open + 1;
|
|
continue;
|
|
}
|
|
const close = content.indexOf("</button>", open);
|
|
if (close === -1) break;
|
|
const segment = content.slice(open, close);
|
|
if (cardOpener.test(segment)) {
|
|
const line = content.slice(0, open).split("\n").length;
|
|
hits.push({ line, excerpt: segment.slice(0, 160).replace(/\s+/g, " ") });
|
|
}
|
|
cursor = close + "</button>".length;
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
describe("static guard: no <button> wrapping a Card primitive", () => {
|
|
const files = listTsxFiles(SRC_ROOT)
|
|
// Test files describe and document patterns; they may legitimately
|
|
// contain regex strings that mention `<button>` and `<Card>` together.
|
|
.filter((f) => !/\.test\.tsx?$/.test(f));
|
|
|
|
it("scans at least one source file (sanity)", () => {
|
|
expect(files.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("finds no <button>…<Card> nesting in any production .tsx file", () => {
|
|
const allHits: Hit[] = [];
|
|
for (const file of files) {
|
|
const content = readFileSync(file, "utf8");
|
|
for (const h of findHits(content)) {
|
|
allHits.push({ file: relative(SRC_ROOT, file), ...h });
|
|
}
|
|
}
|
|
if (allHits.length > 0) {
|
|
const message = allHits
|
|
.map((h) => ` ${h.file}:${h.line} → ${h.excerpt}`)
|
|
.join("\n");
|
|
throw new Error(
|
|
`Invalid HTML nesting detected — <button> wraps a Card primitive:\n${message}\n` +
|
|
`Card renders a <div>, which is flow content and not allowed inside <button>.\n` +
|
|
`Use Card directly as DialogTrigger asChild's child, or place a transparent\n` +
|
|
`<button type="submit"> as a sibling that overlays the card.`,
|
|
);
|
|
}
|
|
expect(allHits).toEqual([]);
|
|
});
|
|
});
|