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,14 +74,14 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</div> </div>
<div className="flex flex-col gap-3"> <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" && ( {account.status !== "connected" && (
<form action={pairAccountAction} className="contents"> <form action={pairAccountAction} className="relative">
<input type="hidden" name="accountId" value={account.id} /> <input type="hidden" name="accountId" value={account.id} />
<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"> <Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -100,7 +100,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<ChevronRightIcon className="size-4 text-muted-foreground/60" /> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent> </CardContent>
</Card> </Card>
</button> <button
type="submit"
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> </form>
)} )}
@ -130,11 +134,12 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Unpair — entire card opens the confirm dialog */} {/* Unpair — entire card opens the confirm dialog */}
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button <Card
type="button" role="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" 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"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10"> <div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
@ -150,7 +155,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<ChevronRightIcon className="size-4 text-muted-foreground/60" /> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent> </CardContent>
</Card> </Card>
</button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -178,11 +182,12 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Delete — entire card opens the confirm dialog */} {/* Delete — entire card opens the confirm dialog */}
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button <Card
type="button" role="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" 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"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10"> <div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
@ -198,7 +203,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<ChevronRightIcon className="size-4 text-muted-foreground/60" /> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent> </CardContent>
</Card> </Card>
</button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>

View File

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

View File

@ -106,16 +106,22 @@ export function AccountsListView({ accounts, deleteFormAction }: AccountsListVie
</Card> </Card>
</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
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> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button <Card
type="button" role="button"
tabIndex={0}
data-testid="account-delete-card" data-testid="account-delete-card"
aria-label={`Delete ${account.label}`} 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"> <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"> <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" /> <Trash2Icon className="size-4 text-destructive" />
@ -128,7 +134,6 @@ export function AccountsListView({ accounts, deleteFormAction }: AccountsListVie
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>