fix(web): switch dialog cards to transparent <button> overlay; add test guards
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>
This commit is contained in:
parent
99fd2584e4
commit
c8199f0bbf
@ -131,31 +131,34 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Unpair — entire card opens the confirm dialog */}
|
{/* Unpair — transparent <button> overlay opens the dialog
|
||||||
|
so we don't pass button-specific props onto the Card div
|
||||||
|
(Radix asChild does that and it produces a hydration
|
||||||
|
mismatch on a div). */}
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||||
<Card
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
role="button"
|
<div className="flex items-center gap-3">
|
||||||
tabIndex={0}
|
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
aria-label="Unpair WhatsApp"
|
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||||
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
|
||||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Unpair</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
<div>
|
||||||
</CardContent>
|
<p className="text-sm font-medium">Unpair</p>
|
||||||
</Card>
|
<p className="text-xs text-muted-foreground">
|
||||||
</DialogTrigger>
|
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Unpair WhatsApp"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Unpair this account?</DialogTitle>
|
<DialogTitle>Unpair this account?</DialogTitle>
|
||||||
@ -179,31 +182,31 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete — entire card opens the confirm dialog */}
|
{/* Delete — transparent <button> overlay opens the dialog. */}
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
<Card
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
role="button"
|
<div className="flex items-center gap-3">
|
||||||
tabIndex={0}
|
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
aria-label="Delete account"
|
<Trash2Icon className="size-4 text-destructive" />
|
||||||
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Remove the account and all its reminders, groups, and history
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
<div>
|
||||||
</CardContent>
|
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||||
</Card>
|
<p className="text-xs text-muted-foreground">
|
||||||
</DialogTrigger>
|
Remove the account and all its reminders, groups, and history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete account"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
<DialogTitle>Delete this account permanently?</DialogTitle>
|
||||||
|
|||||||
@ -109,20 +109,21 @@ describe("AccountsListView", () => {
|
|||||||
expect(html).toMatch(/<strong>MyBiz<\/strong>/);
|
expect(html).toMatch(/<strong>MyBiz<\/strong>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delete card is a focusable trigger element with the destructive aria-label", () => {
|
it("delete card uses a real <button> overlay (not a div with role=button)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView
|
||||||
accounts={[mkAccount({ label: "Sales" })]}
|
accounts={[mkAccount({ label: "Sales" })]}
|
||||||
deleteFormAction={noopAction}
|
deleteFormAction={noopAction}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// The trigger is a Card (rendered as <div>) acting as a button via
|
// The visible Card stays a <div>; a real <button type="button">
|
||||||
// role+tabIndex. Wrapping a <div> in a real <button> would be
|
// overlays it. Putting a <div> inside a <button> would be invalid
|
||||||
// invalid HTML and trigger a hydration mismatch.
|
// HTML, and Radix's `asChild` injecting button props onto a <div>
|
||||||
expect(html).toMatch(/role="button"/);
|
// would mismatch SSR vs client. The overlay sidesteps both.
|
||||||
expect(html).toMatch(/tabIndex="0"|tabindex="0"/);
|
expect(html).toMatch(
|
||||||
|
/<button[^>]*type="button"[^>]*data-testid="account-delete-card"|<button[^>]*data-testid="account-delete-card"[^>]*type="button"/,
|
||||||
|
);
|
||||||
expect(html).toMatch(/aria-label="Delete Sales"/);
|
expect(html).toMatch(/aria-label="Delete Sales"/);
|
||||||
expect(html).toMatch(/data-testid="account-delete-card"/);
|
|
||||||
// `Delete account` heading copy lives inside the card
|
// `Delete account` heading copy lives inside the card
|
||||||
expect(html).toContain("Delete account");
|
expect(html).toContain("Delete account");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -107,34 +107,34 @@ export function AccountsListView({ accounts, deleteFormAction }: AccountsListVie
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Dedicated Delete card — entire card is the dialog trigger.
|
{/* Dedicated Delete card — entire card is the dialog trigger.
|
||||||
We avoid wrapping the Card (a <div>) in a <button> because
|
We use a transparent <button> overlay instead of passing
|
||||||
div-inside-button is invalid HTML and the browser auto-
|
the Card as `asChild` to DialogTrigger: Radix injects
|
||||||
closes the button, breaking SSR hydration. Radix's
|
`type="button"` and other button-specific props onto its
|
||||||
DialogTrigger asChild forwards the click handler to the
|
child, which on a <div> mismatches between SSR and the
|
||||||
Card element directly; role="button"+tabIndex makes it
|
client tree. Putting a real <button> over the card keeps
|
||||||
keyboard-focusable. */}
|
the visible Card as a <div> with valid attributes. */}
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
<Card
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
role="button"
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
tabIndex={0}
|
<Trash2Icon className="size-4 text-destructive" />
|
||||||
data-testid="account-delete-card"
|
</div>
|
||||||
aria-label={`Delete ${account.label}`}
|
<div className="min-w-0 flex-1">
|
||||||
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
<p className="text-sm font-medium text-destructive">Delete account</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
Remove {account.label} and its reminders & groups
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
</p>
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="min-w-0 flex-1">
|
<DialogTrigger asChild>
|
||||||
<p className="text-sm font-medium text-destructive">Delete account</p>
|
<button
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
type="button"
|
||||||
Remove {account.label} and its reminders & groups
|
data-testid="account-delete-card"
|
||||||
</p>
|
aria-label={`Delete ${account.label}`}
|
||||||
</div>
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
</Card>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete this account?</DialogTitle>
|
<DialogTitle>Delete this account?</DialogTitle>
|
||||||
|
|||||||
@ -95,51 +95,53 @@ export function EditAccountForm({
|
|||||||
const active = account.id === selected;
|
const active = account.id === selected;
|
||||||
const connected = account.status === "connected";
|
const connected = account.status === "connected";
|
||||||
return (
|
return (
|
||||||
<button
|
<Card
|
||||||
key={account.id}
|
key={account.id}
|
||||||
type="button"
|
role="button"
|
||||||
onClick={() => setSelected(account.id)}
|
tabIndex={0}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
|
aria-label={`Use ${account.label}`}
|
||||||
|
onClick={() => setSelected(account.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelected(account.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"transition-all rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
active
|
||||||
|
? "ring-2 ring-primary/60"
|
||||||
|
: "hover:shadow-md hover:ring-primary/30 cursor-pointer",
|
||||||
|
!connected && "opacity-70",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
className={cn(
|
<div
|
||||||
"transition-all",
|
className={cn(
|
||||||
active
|
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||||
? "ring-2 ring-primary/60"
|
connected
|
||||||
: "hover:shadow-md hover:ring-primary/30 cursor-pointer",
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
!connected && "opacity-70",
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
|
||||||
connected
|
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "bg-muted text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SmartphoneIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium leading-snug truncate">
|
|
||||||
{account.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{account.phoneNumber ?? "Not paired"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{connected ? (
|
|
||||||
<WifiIcon className="size-4 text-emerald-500 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<WifiOffIcon className="size-4 text-muted-foreground/50 shrink-0" />
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<SmartphoneIcon className="size-4" />
|
||||||
</button>
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium leading-snug truncate">
|
||||||
|
{account.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{account.phoneNumber ?? "Not paired"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected ? (
|
||||||
|
<WifiIcon className="size-4 text-emerald-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<WifiOffIcon className="size-4 text-muted-foreground/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
apps/web/src/test/no-button-wrapping-card.test.ts
Normal file
103
apps/web/src/test/no-button-wrapping-card.test.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
apps/web/src/test/no-render-warnings.test.tsx
Normal file
192
apps/web/src/test/no-render-warnings.test.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import type { ReactNode, ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: server-rendering critical surfaces must not produce ANY
|
||||||
|
* console.error or console.warn from React. The most common sources are:
|
||||||
|
*
|
||||||
|
* - Invalid HTML nesting (e.g. <div> inside <button>) — this is exactly
|
||||||
|
* what produced the recent "Hydration failed" runtime error.
|
||||||
|
* - Two children with the same `key`.
|
||||||
|
* - "Encountered a script tag while rendering React component" — a
|
||||||
|
* React 19 warning when a `<script>` appears inside the React tree
|
||||||
|
* rather than `<head>`.
|
||||||
|
*
|
||||||
|
* We can't fully reproduce *runtime* hydration in a Node test, but
|
||||||
|
* `renderToString` runs React's SSR-time validation, which catches
|
||||||
|
* these structural issues during the same render that would later
|
||||||
|
* mismatch in the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Mocks for next/link + the radix primitives we use --------------------
|
||||||
|
// These are deliberately TRANSPARENT (render children) so the underlying
|
||||||
|
// element tree we care about (button vs div nesting, etc.) reaches React's
|
||||||
|
// validator unchanged.
|
||||||
|
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record<string, unknown>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dialog", () => ({
|
||||||
|
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
|
||||||
|
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AccountsListView, type AccountsListAccount } from "@/components/accounts-list-view";
|
||||||
|
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
|
||||||
|
|
||||||
|
// next-themes / radix dropdown mocks for ThemeToggle.
|
||||||
|
const useThemeReturn: { theme: string | undefined; setTheme: () => void } = {
|
||||||
|
theme: "system",
|
||||||
|
setTheme: () => {},
|
||||||
|
};
|
||||||
|
vi.mock("next-themes", () => ({
|
||||||
|
useTheme: () => useThemeReturn,
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div role="menuitem">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// next/navigation is touched by the edit form's useRouter().
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: () => {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the server action import inside EditMessageForm so importing it
|
||||||
|
// doesn't try to wire up the real action machinery.
|
||||||
|
vi.mock("@/actions/reminders", () => ({
|
||||||
|
updateReminderAction: () => Promise.resolve({ ok: true, reminderId: "r-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
// Helper: render with console.error / console.warn captured. Restores the
|
||||||
|
// originals via vi.restoreAllMocks at end of each test.
|
||||||
|
function renderQuiet(node: ReactElement): { html: string; errors: unknown[][]; warns: unknown[][] } {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const html = renderToString(node);
|
||||||
|
const errors = errorSpy.mock.calls;
|
||||||
|
const warns = warnSpy.mock.calls;
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
return { html, errors, warns };
|
||||||
|
}
|
||||||
|
|
||||||
|
const account: AccountsListAccount = {
|
||||||
|
id: "a-1",
|
||||||
|
label: "Personal",
|
||||||
|
status: "connected",
|
||||||
|
phoneNumber: "+60123456789",
|
||||||
|
lastConnectedAt: new Date("2026-05-01T10:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SSR render — no React errors or warnings", () => {
|
||||||
|
it("AccountsListView (populated) renders cleanly", () => {
|
||||||
|
const { errors, warns } = renderQuiet(
|
||||||
|
<AccountsListView accounts={[account]} deleteFormAction={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(warns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AccountsListView (empty) renders cleanly", () => {
|
||||||
|
const { errors, warns } = renderQuiet(
|
||||||
|
<AccountsListView accounts={[]} deleteFormAction={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(warns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ThemeToggle renders cleanly across all theme values", () => {
|
||||||
|
for (const t of ["light", "dark", "system", undefined]) {
|
||||||
|
useThemeReturn.theme = t;
|
||||||
|
const { errors, warns } = renderQuiet(<ThemeToggle />);
|
||||||
|
expect(errors, `console.error during theme=${t}`).toEqual([]);
|
||||||
|
expect(warns, `console.warn during theme=${t}`).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EditMessageForm renders cleanly (text-only and media-attached)", () => {
|
||||||
|
const baseProps = {
|
||||||
|
reminderId: "r-1",
|
||||||
|
accountId: "acc-1",
|
||||||
|
groupIds: ["g-1"],
|
||||||
|
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||||
|
rrule: "FREQ=DAILY",
|
||||||
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
initialText: "Hello",
|
||||||
|
initialMediaId: null as string | null,
|
||||||
|
initialCaption: "",
|
||||||
|
};
|
||||||
|
const a = renderQuiet(<EditMessageForm {...baseProps} />);
|
||||||
|
expect(a.errors).toEqual([]);
|
||||||
|
expect(a.warns).toEqual([]);
|
||||||
|
|
||||||
|
const b = renderQuiet(
|
||||||
|
<EditMessageForm
|
||||||
|
{...baseProps}
|
||||||
|
initialMediaId="m-1"
|
||||||
|
initialCaption="caption text"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(b.errors).toEqual([]);
|
||||||
|
expect(b.warns).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () => {
|
||||||
|
it("AccountsListView never nests a div inside a button", () => {
|
||||||
|
const { html } = renderQuiet(
|
||||||
|
<AccountsListView accounts={[account]} deleteFormAction={() => {}} />,
|
||||||
|
);
|
||||||
|
// Naive but effective: scan each <button>...</button> region for any
|
||||||
|
// <div, <p, or <h-tag inside. Those are flow content and not allowed
|
||||||
|
// inside a button per the HTML spec — browsers auto-close the button
|
||||||
|
// and the SSR/client trees diverge.
|
||||||
|
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];
|
||||||
|
for (const m of buttons) {
|
||||||
|
const inner = m[1] ?? "";
|
||||||
|
expect(
|
||||||
|
/<(?:div|p|h[1-6])\b/.test(inner),
|
||||||
|
`Invalid nesting inside <button>: ${inner.slice(0, 200)}`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EditMessageForm never nests a div inside a button", () => {
|
||||||
|
const { html } = renderQuiet(
|
||||||
|
<EditMessageForm
|
||||||
|
reminderId="r-1"
|
||||||
|
accountId="acc-1"
|
||||||
|
groupIds={["g-1"]}
|
||||||
|
scheduledAtIso="2026-05-13T09:00:00.000+08:00"
|
||||||
|
rrule={null}
|
||||||
|
timezone="Asia/Kuala_Lumpur"
|
||||||
|
initialText="Hello"
|
||||||
|
initialMediaId={null}
|
||||||
|
initialCaption=""
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];
|
||||||
|
for (const m of buttons) {
|
||||||
|
const inner = m[1] ?? "";
|
||||||
|
expect(/<(?:div|p|h[1-6])\b/.test(inner)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user