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,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>
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user