fix(web): drop <button>-wrapping-<Card> — div inside button is invalid HTML

Root cause of the hydration mismatch:

  <button type="button">       ← React 19 server output
    <Card>                     ← <div> from shadcn Card
      <CardContent>...

`<div>` is flow content and is NOT allowed inside `<button>` per the
HTML spec. Browsers auto-close the outer `<button>` when they hit the
nested `<div>`, while React's SSR doesn't — the server tree and the
post-parse client tree disagree, and React 19 throws Hydration failed.

Fix: stop nesting Card inside button-shaped triggers. Three sites
touched, all on the account list / detail pages:

- Accounts list — Delete card per row
- Account detail — Unpair card
- Account detail — Delete card

For these the trigger is a Dialog. Radix's DialogTrigger asChild
forwards click handling to whatever element you give it, so we now
pass the Card directly with role="button" / tabIndex / aria-label.
The Card stays a <div>, no invalid nesting.

- Account detail — Pair / Re-pair card

This one wraps a server action `<form>`, which still requires a real
`<button type="submit">`. Solution: keep the Card as a sibling of an
absolute-positioned transparent submit button covering the card's
surface — the whole card surface still triggers submit, but the
visible Card never lives inside the button, and HTML stays valid.

Updated `accounts-list-view.test.tsx` to match: the delete card's
trigger is now a `<div role="button" tabIndex="0">` instead of a
real button.

92/92 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 09:11:35 +08:00
parent 2b71ebeb17
commit 99fd2584e4
3 changed files with 96 additions and 84 deletions

View File

@ -74,33 +74,37 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</div>
<div className="flex flex-col gap-3">
{/* Pair / Re-pair — entire card is the submit button */}
{/* Pair / Re-pair keep the form-submit semantics. The whole
card surface is still the click target via a transparent
overlay submit button positioned over the card; the visible
Card stays a <div>, so we never nest a <div> inside a
<button> (invalid HTML SSR hydration mismatch). */}
{account.status !== "connected" && (
<form action={pairAccountAction} className="contents">
<form action={pairAccountAction} className="relative">
<input type="hidden" name="accountId" value={account.id} />
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<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-emerald-500/10">
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
</p>
<p className="text-xs text-muted-foreground">
Show a QR code so this account can connect to WhatsApp
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<button
type="submit"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<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-emerald-500/10">
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
</p>
<p className="text-xs text-muted-foreground">
Show a QR code so this account can connect to WhatsApp
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</button>
aria-label={account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair 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"
/>
</form>
)}
@ -130,27 +134,27 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Unpair — entire card opens the confirm dialog */}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
<Card
role="button"
tabIndex={0}
aria-label="Unpair WhatsApp"
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"
>
<Card className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<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>
<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>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</button>
<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>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
@ -178,27 +182,27 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Delete — entire card opens the confirm dialog */}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
<Card
role="button"
tabIndex={0}
aria-label="Delete account"
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"
>
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<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>
<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>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</button>
<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>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>

View File

@ -109,17 +109,20 @@ describe("AccountsListView", () => {
expect(html).toMatch(/<strong>MyBiz<\/strong>/);
});
it("delete card is a button with the destructive aria-label", () => {
it("delete card is a focusable trigger element with the destructive aria-label", () => {
const html = renderToStaticMarkup(
<AccountsListView
accounts={[mkAccount({ label: "Sales" })]}
deleteFormAction={noopAction}
/>,
);
// Order of attributes in the rendered <button> isn't guaranteed, so
// assert each one exists and that they're on a button element.
expect(html).toMatch(/<button[^>]*\baria-label="Delete Sales"/);
expect(html).toMatch(/<button[^>]*\bdata-testid="account-delete-card"/);
// The trigger is a Card (rendered as <div>) acting as a button via
// role+tabIndex. Wrapping a <div> in a real <button> would be
// invalid HTML and trigger a hydration mismatch.
expect(html).toMatch(/role="button"/);
expect(html).toMatch(/tabIndex="0"|tabindex="0"/);
expect(html).toMatch(/aria-label="Delete Sales"/);
expect(html).toMatch(/data-testid="account-delete-card"/);
// `Delete account` heading copy lives inside the card
expect(html).toContain("Delete account");
});

View File

@ -106,29 +106,34 @@ export function AccountsListView({ accounts, deleteFormAction }: AccountsListVie
</Card>
</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
div-inside-button is invalid HTML and the browser auto-
closes the button, breaking SSR hydration. Radix's
DialogTrigger asChild forwards the click handler to the
Card element directly; role="button"+tabIndex makes it
keyboard-focusable. */}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
<Card
role="button"
tabIndex={0}
data-testid="account-delete-card"
aria-label={`Delete ${account.label}`}
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
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"
>
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Delete account</p>
<p className="text-xs text-muted-foreground truncate">
Remove {account.label} and its reminders & groups
</p>
</div>
</CardContent>
</Card>
</button>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Delete account</p>
<p className="text-xs text-muted-foreground truncate">
Remove {account.label} and its reminders & groups
</p>
</div>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>