fix(components): keep overlay trigger buttons inside their trigger (part of #3014) - #3025
fix(components): keep overlay trigger buttons inside their trigger (part of #3014)#3025mfal wants to merge 7 commits into
Conversation
…place
Two react-aria dev warnings, two real defects.
`Tabs` passed `selectedKey={undefined}` on the first render whenever neither
`selectedKey` nor `defaultSelectedKey` was given. The tab titles reach the tab
list through a tunnel, so `Aria.Tabs` starts with an empty collection and picks
the default tab one commit later. That selection landed in the internal state
and flipped `Aria.Tabs` from uncontrolled to controlled — the transition
`useControlledState` warns about. Passing `null` keeps it controlled from the
first render; `useControlledState` treats only `undefined` as uncontrolled.
`SectionHeader` tunnels every `Button` into its actions slot and let each
overlay trigger opt its own button out with a nested `Button: { tunnel: null }`
— but had no entry for `ModalTrigger`. The trigger button was lifted out of the
trigger's subtree, so react-aria's `PressResponder` found no pressable child and
`aria-haspopup`, `aria-expanded`, `aria-controls` and the trigger ref never
reached the button. It only kept working because Flow supplies `onPress` through
its own props context.
Fixed at the root instead of per host: `OverlayTrigger` pins its button with
`tunnel: null`, and `overlayTriggersTunneledTo` gives a host one place to send
the triggers themselves. `SectionHeader`, `Label`, `ListItemView` and
`MarkdownEditor` use it — the first three were each missing entries, so the
class of bug is now closed by construction. Rendered output is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report for ./packages/components/
File Coverage
|
||||||||||||||||||||||||||||||||||||||
❌ Visual Regression Tests FailedAt least one shard did not pass. If snapshots differ from the committed baselines, download the visual-diffs-* artifacts from this run to inspect the actual/diff images (a shard that failed before comparison, e.g. during install or browser setup, leaves none). If the differences are intentional, update the baselines by adding the |
🚀 Preview DeploymentPreview environments are ready:
Images:
|
Correction: rendered output does changeThe PR body claims "rendered output is unchanged". That is wrong, and
That is the trigger button now sitting behind the modal's backdrop like every other element on the page. Before, hoisted out of its trigger's subtree by It is still a visual change to a shipped component, so it wants a deliberate confirmation before the The second failure was a flake
🤖 Addressed by Claude Code |
Rerun settles both failuresRe-ran the two failing shards:
So the suite is otherwise green and the only red is the one baseline this fix legitimately changes. That is the precondition for Expected commit contents: two files only — Reviewer note: the changed PNG is the visual record of the fix — the trigger button now dimmed behind the modal backdrop instead of punching through it. Worth a look at the image rather than just the green check. 🤖 Addressed by Claude Code |
Retracting my previous explanationI wrote above that the new frame "looks like the correct one" — trigger button now dimmed behind the backdrop. That is not supported by the evidence, and applying So what is actually measured:
The fix therefore does not deterministically change this rendering. It renders identically to Why the scenario is exposedawait trigger.click();
await testScreenshot("Modal in Section Header - ContextMenu opened");Nothing synchronizes the capture beyond the preamble. Per this package's own The trigger button is the one element whose appearance depends on whether the overlay is over it yet, so moving it inside its trigger gave that pre-existing window a visible consequence. The race is the defect here, not the fix. What I did not doNo baseline was rewritten and nothing was pushed — the branch is unchanged. Rewriting the reference would have entrenched whichever frame a run happened to catch, which is the exact failure mode of #2945/#2985. Making the capture deterministic is the real fix, and it needs a CI round trip to verify (a macOS run only writes 🤖 Addressed by Claude Code |
Diagnosis complete: the baseline is right, the scenario is racy under shardingReproduced locally on this branch (fresh worktree, load average ~5) and measured the settled state directly:
So with the fix in place the settled render equals the committed baseline, and the race does not reproduce on a fast, idle machine at all. Combined with the two earlier signals — the unsharded CI The committed baseline is the settled state. The ~1% frames are early captures, and they only surface under CI's sharded timing. Why this fix exposed itThe scenario captures immediately after Proposed fix — not pushedThe capture needs to synchronize on the thing it depends on, in the spirit of this package's await trigger.click();
// The capture encodes whether the overlay sits over the trigger; that is
// stacking, not a mutation, so the screenshot preamble cannot see it settle.
await expect
.poll(() => {
const el = page.getByTestId("trigger").element();
const { x, y, width, height } = el.getBoundingClientRect();
return el.contains(
document.elementFromPoint(x + width / 2, y + height / 2),
);
}, { timeout: 2000 })
.toBe(true);
await testScreenshot("Modal in Section Header - ContextMenu opened");I have deliberately not pushed this. The mechanism is inferred from the diff image, and it cannot be validated anywhere but CI — the race does not reproduce locally, so a local green run would prove nothing. Pushing an unvalidated guess onto a PR that is already under review, as the third attempt at the same red check, seemed worse than handing over a finished diagnosis. Say the word and I'll push it and watch the shards. Nothing was committed to the branch; the temporary worktree is removed. 🤖 Addressed by Claude Code |
… too The `tunnel: null` pin `OverlayTrigger` sets covers the local tree only, so the `Modal in Section Header` visual scenario diverged: `Local` rendered the fixed output, `Remote` the old one. Both environments share one screenshot reference — `resolveScreenshotPath` keys it on the description, not the test name — so the scenario could not go green, and `--update` just let the two overwrite each other for a net-zero diff. `ModalTrigger`, `PopoverTrigger` and `LightBoxTrigger` are not `@flr-generate`. A remote tree emits `DialogTriggerView`, and the host materialises a `DialogTrigger` with no `OverlayTrigger` around it — so a props context keyed on `ModalTrigger` never matched, `SectionHeader` lifted the button out of the react-aria trigger, and `aria-expanded` never reached it. Same defect the local fix addressed, one tree over. `DialogTrigger` is now a `flowComponent` that pins its own button, and `overlayTriggersTunneledTo` sends it into the tunnel as a whole; `OverlayTrigger` pins it in turn so the local tree keeps tunneling the trigger the author wrote instead of the one inside it. Registering the name alone breaks remote rendering: `createFlowRemoteComponent` builds every registered name as a `"ui"` component, whose `ClearPropsContext` cuts the `onPress` the remote `OverlayTrigger` just set — remote modals stopped opening at all. `@flr-provider` carries the provision type over to the generated remote component, which is what keeps that context intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both sides appended to the "Common failures" table. Kept main's four new rows and this branch's row about a single-environment visual failure that `update-screenshots` cannot fix, in the position it had here.
The fix I proposed above is wrong — do not use itI tried the The trigger is never the topmost element at its own position while the modal is open — the backdrop captures hit-testing across the whole viewport. Notably the same run confirms the local render is stable and matches the reference — the scenario passed the screenshot assertion three times in a row without any wait, and the only local failure is my own bad poll. Where that leaves itStill established, unchanged:
Not established: what differs in the sharded environment. I had a plausible mechanism, tested it, and it was wrong — so I have no mechanism now, and I am not going to push another guess onto a PR under review. Finding it needs someone who can iterate against CI, since it does not reproduce locally at all. The branch is untouched by all of this; only the 🤖 Addressed by Claude Code |
Co-authored-by: mfal <4696750+mfal@users.noreply.github.com>
✅ Cross-Version Tests PassedThe current host still renders old published remote versions correctly (iframe) and in the same DOM shape (in-process). Iframe harness (attribute-accurate): ✅ passed In-process harness (structure-only): ✅ passed |
Green now — and my "sharded vs unsharded" framing was wrong tooAll checks pass: 20 pass, 0 fail. I checked whether the shard composition had shifted, since that was my last standing hypothesis. It had not:
Same shard, same co-tenants, same list. So the difference is not sharding, and I withdraw that framing along with the What the record actually supportsThe baseline matched in six measurements — four local runs (three plain, one with an explicit 2.5 s settle), the unsharded CI Two explanations remain, and I cannot separate them from this evidence:
What is settled, in every direction it was tested: the divergence was never this fix's settled rendering. That claim of mine from the first comment was the wrong one, and rewriting the baseline — which I nearly did — would have replaced a correct reference with a transient frame. Nothing to do hereNo push needed; the PR is unblocked and ready for review. The latent fragility of this scenario is worth watching if it resurfaces — the root 🤖 Addressed by Claude Code |
#3045 added `Tabs.browser.test.tsx` for its `aria-label` forwarding while this branch added one for the uncontrolled-to-controlled fix — an add/add conflict. Both test sets are kept in one file; they cover different things and neither needed changing. `Tabs.tsx` and `TabList.tsx` auto-merged cleanly: main's aria-label plumbing and this branch's `?? null` sit side by side.
The label pushes its baseline commit as `github-actions[bot]`, so every run on the new head lands in `completed`/`action_required` — created, never executed. The PR then reads as settled while it has no test run at all, and `approve-workflow-run` 404s on those runs because they are not awaiting approval. `gh run rerun` is what gets them going.
Part of #3014 — the
A PressResponder was rendered without a pressable childhalf. Theuncontrolled to controlledhalf is split out into #3048. Neither PR closes the issue on its own; close it once both are merged.The warning was hiding a real accessibility defect rather than just noise — in both rendering paths, as the visual suite then proved.
A PressResponder was rendered without a pressable child(8×) —ModalTrigger, and it cost real a11yNot
ContextMenuTrigger. Confirmed withcaptureOwnerStack(): owner wasFlowComponent(ModalTrigger)→OverlayTrigger→ RACDialogTrigger.SectionHeader's props context tunnels everyButtoninto its actions slot, and each overlay trigger opts its own button out with a nestedButton: { tunnel: null }— but there was noModalTriggerentry. So the trigger button was lifted out of the trigger's React subtree, RAC'sPressResponderfound nousePresschild, andaria-expanded,aria-controlsand the trigger ref never reached the button. It only kept working at all because Flow's own props context suppliesonPress.Fixed structurally rather than by adding one more entry to the list:
OverlayTriggernow pins its own button withtunnel: null— a trigger's button is the react-aria trigger and may never be tunneled away — and a newoverlayTriggersTunneledTo(tunnel)helper gives a host one place to send the triggers themselves.SectionHeader,LabelandListItemViewwere each missing entries;MarkdownEditoralready had three. All four now use the helper, so the whole class is closed by construction rather than one component at a time. The invariant is recorded inpackages/components/AGENTS.md.The button still lands in the same slot, now inside its trigger. It does render differently, though: behind the modal backdrop it is now dimmed like the rest of the page instead of punching through it. That is the baseline change in this PR.
The host sees a different component, so the same defect survived in
RemoteOverlayTrigger's props context covers the local tree only.ModalTrigger,PopoverTriggerandLightBoxTriggerare not@flr-generate: a remote tree emitsDialogTriggerView, and the host materialises a bareDialogTriggerwith noOverlayTriggeraround it. A context keyed onModalTriggernever matched there,SectionHeaderkept lifting the button out of the react-aria trigger, andaria-expandednever arrived — the identical defect, one tree over, and pre-existing (PopoverTriggerhad aSectionHeaderentry before this PR and the same asymmetry).Measured on the
Modal in Section Headerscenario, trigger button:Localaria-expandedabsent"false"→"true"on openRemotearia-expandedabsent"false"→"true"on openThree pieces close it:
DialogTriggeris now aflowComponentthat pinsButton: { tunnel: null }itself — the invariant now sits where thePressResponderactually is, not only where the author wrote the trigger.overlayTriggersTunneledTotunnelsDialogTriggeras a whole, so the host sends the trigger — button included — into the actions slot.OverlayTriggerpinsDialogTrigger: { tunnel: null }in turn, so the local tree keeps tunneling the trigger the author wrote and never the one inside it.@flr-provider: registering the name alone breaks remote renderingRegistering
DialogTriggerin the props-type registry is not free.createFlowRemoteComponentbuilds every registered name throughflowComponent, which defaults totype: "ui"— and a UI component wraps its children inClearPropsContext. That cut theonPressthe remoteOverlayTriggerhad just set, and remote modals stopped opening at all, standalone ones included. Nothing errored; the click was simply inert.So the provision type has to travel with the generated component.
@flr-provideron the component's JSDoc makes the generator emittype: "provider", andcreateFlowRemoteComponentpasses it through. It only matters where a provider actually runs inside the remote tree — i.e. where the component rendering it is not itself@flr-generate— soDialogTriggeris the only component carrying the tag today.Why
update-screenshotsreported "No Screenshot Updates Required"resolveScreenshotPathkeys a reference on the screenshot description, not the test name, so(Local)and(Remote)share one baseline file. While the two environments disagreed,--updatelet each write it in turn and the last one won:Localwrote the fixed image,Remoteoverwrote it with the old one, andgit statuscame back clean. The label could never have fixed this scenario — the divergence had to go first. With both environments agreeing, the same label produced the expected two-file commit. Both traps are now rows in the rootAGENTS.md.Verified
captureOwnerStack()to name the culprit: owner wasFlowComponent(ModalTrigger)→OverlayTrigger→ RACDialogTrigger, notContextMenuTrigger.OverlayTrigger'stunnel: nullfails thearia-expandedand PressResponder tests; reverting only theSectionHeaderentry fails the actions-slot test.propTypesregistration alone reproduced the broken remote modal, which is what surfaced theClearPropsContextcause.OverlayTriggerInTunnel.browser.test.tsx): red onRemote, green onLocalbefore the host-side fix; green on both after. It asserts behaviour —aria-expandedpresent and flipping, button inside the header's actions container.Local205a37f5…vsRemote8a5a9774…before, identical205a37f5…after.Modal in Section Headeris the only scenario that changed. CI agrees — every other Linux shard is green and shard 3 fails only these two, both by the same 1218 px.pnpm lint0 errors,pnpm testgreen, 256 component browser tests, 12 remote browser tests, 30 e2e green on webkit,git statusclean afterpnpm build.run-cross-version-testson this PR: the host output for a remote overlay trigger inside a tunneling context moves, so both harnesses run rather than only the PR-gated iframe one.Baselines updated for the one scenario:
-darwinlocally,-linuxby theupdate-screenshotslabel — two files, nothing else.Follow-up
Beyond the
Tabssource in #3048, a furtheruncontrolled to controlledsource exists inModal—isOpengoesundefined→ defined between the prop-driven and controller-driven paths, 9 occurrences inModal.browser.test.tsx. Verified pre-existing (identical count with these changes reverted) and outside this issue's reproduction list, so left alone. Filed separately.MenuTriggerandTooltipTriggerare the same shape asDialogTrigger— a react-aria trigger wrapper whose button a surrounding context could tunnel away. Neither is reachable through a tunneling context today (MenuTriggeronly renders inside the@flr-generateContextMenuTrigger, so the host runs a realOverlayTriggeraround it;TooltipTriggeris not a registered props-context name at all), so both are left untouched rather than changed without a failing case.🤖 Generated with Claude Code