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:
parent
2b71ebeb17
commit
99fd2584e4
@ -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>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user