fix(frontend): badge trigger is not exposed as a button (audit finding 3) - #62
Merged
Conversation
…idden
Two audit findings in one component.
a11y (finding 3): the trigger renders through Badge, which is a <span>,
while Base UI defaults nativeButton to true -- so it emitted type="button"
on a span and left the element with no role at all. Screen readers
announced an unlabelled span. The code comment claimed Popover.Trigger
renders a <button>; a Base UI upgrade had made that false, and Base UI was
warning about it at runtime. nativeButton={false} makes it apply
role="button" plus the non-native keyboard handling.
Activity (finding 2): Popover.Portal mounts the popup into document.body,
outside the <Activity> boundary, so display:none on the boundary never
reaches it and a popover pinned open when the user hits browser-back stays
painted over the next route.
Worth recording that the fix the Next guide prescribes -- setOpen(false)
in a useLayoutEffect cleanup -- is NOT sufficient for portaled content.
Verified directly: the cleanup runs, but the update is scheduled against a
subtree React has just hidden, hidden Activity content re-renders at low
priority, and data-open is still on the popup afterwards. Nothing hides
the portal in the meantime. So drop the positioner out of the layout
synchronously in the cleanup, then close the state for the re-show.
…rowser Verified finding 2 against real Chrome over CDP and it is not a bug. The jsdom probe that motivated it was an artifact of happy-dom's portal handling. Method: land on /, navigate client-side via the search bar so the App Router keeps / in an Activity boundary (Page.navigate is a full document load and tears the popup down regardless -- the first probe run made exactly that mistake and proved nothing), pin a popover open, then history.back(). On unmodified main the popup already reports hiddenAncestor: true, painted: false, w=0, h=0. React's display:none does reach the portal in a real browser. So the fix bought nothing and cost controlled state, a layout effect, direct DOM manipulation and an eslint-disable. Reverted. The a11y half of the PR is unaffected and independently confirmed: Chrome's accessibility tree reports the trigger as 'generic' on main and 'button' with the fix.
Shaan-alpha
added a commit
that referenced
this pull request
Jul 31, 2026
Browser verification showed the popover Activity leak is not a real bug -- main already reports painted:false after browser-back. The fix was reverted in #62 and the user-facing changelog line is removed rather than left claiming a fix for something that was never broken. Also records both process lessons: the first CDP run used Page.navigate (a full document load) and proved nothing, and a jsdom-only failure is not evidence of a product bug when portals, layout or paint are involved.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What ships: the badge trigger was not a button (finding 3)
The trigger renders through
Badge, which is a<span>. Base UI defaultsnativeButtontotrue, so it puttype="button"on a span and left the element with no role at all.Confirmed in real Chrome via the accessibility tree, not just the attribute:
roleattrmainnullgenericbuttonbuttongenericmeans the evidence behind every badge was unreachable to a screen reader. The comment above the component claimed "Popover.Trigger renders a<button>" — a Base UI upgrade made that false at some point. Base UI has been warning about it at runtime the whole time (expected a native <button> because the nativeButton prop is true); the warning is even in the known-noise list in.claude/skills/verify, so it was being actively ignored rather than missed.nativeButton={false}tells Base UI the truth, anduseButtonthen appliesrole="button"plus the non-native keyboard handling. Confirmed against the Base UI docs rather than guessed.What got reverted: the Activity popover leak (finding 2)
The audit's jsdom probe suggested that because
Popover.Portalmounts intodocument.body— outside the<Activity>boundary — a popover pinned open when the user hits browser-back would stay painted over the next route. I wrote a fix for it. Real Chrome says there is no such bug.The first probe run was itself wrong and worth flagging: it used
Page.navigateto reach the report, which is a full document load, so "back" tore the document down and the popup was trivially gone on every branch. That proved nothing. Re-driven properly — land on/, navigate client-side through the app's own search bar so the App Router keeps/alive in an Activity boundary, pin a popover, thenhistory.back():React's
display: nonedoes reach the portal in a real browser. The popup is already not painted without any fix. happy-dom's portal handling differs from Chrome's, and that difference is the whole of the original "evidence."So the fix bought nothing and cost controlled
Popover.Rootstate, auseLayoutEffect, direct DOM manipulation of the positioner, and aneslint-disable. Reverted.The vitest suite now has no
<Activity>test for this component, with a comment explaining why — a test that only fails under happy-dom is asserting the test environment, not the product.Verification
vitest run76 passed / 24 files,tsc --noEmitclean,eslint srcclean, plus the CDP run above against both branches.Console errors during the run were unchanged from the known-noise set (
getServerSnapshot, uncached-promise suspension) with one difference in this PR's favour: the Base UInativeButtonwarning is present onmainand absent here.Not drivable locally, so not claimed either way: signed-in and
/share/{slug}variants (noDATABASE_URL/OAuth inbackend/.env).Merge order
CI here stays red on Backend until #60 merges —
mainis red from a fixture time bomb unrelated to this. No file overlap with #59, #60, #61 or #63.CHANGELOG/PROGRESS_LOGentries for the batch are consolidated in #59.