Sheet: an enter frame at the viewport's edge leaves the sheet never entered, and a Select pressed during the rise opens an inert list

Aperta
#10,937 3 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
38/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
react, typescript

Direzione di ricerca

Read SheetOverlay and the IntersectionObserver setup in the sheet implementation, then trace how UNSTABLE_deferUntilEntered invokes ariaHideOutside in useModalOverlay. Reproduce both timing cases from the issue and inspect the existing sheet and overlay tests. Done means both defects have focused fixes and tests: edge-adjacent entry is recognized without breaking exit, and a Select opened during entry remains interactive.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

These are two separate defects in SheetOverlay. Both happen while a sheet slides in, and I found them together, but they have different causes and different fixes. If you would rather track them apart, please say so and I will split this.

Versions: react-aria-components 1.22.1 and react-aria 3.53.1, the latest releases. The expressions in defect 1 are unchanged on main as of 7 November 2026.

Browsers: Playwright 1.64.1's builds of Chromium 156 and WebKit, on Windows 11, with Playwright's iPhone 17 profile. I have not tested a real iPhone or Linux.

Related, and different: #10813 and #10897 lose the last report of an exit. #10877 is a scroll position written during an exit. Defect 1 is about the enter, and every report arrives. #10814 reports part of defect 2 for a ComboBox.


Defect 1: a frame exactly at the viewport's edge leaves the sheet never entered

What goes wrong

SheetOverlay observes the content with threshold: [0, 1] and reads entry.intersectionRatio > 0:

isVisible.current = entry.intersectionRatio > 0;
hasEntered ||= entry.intersectionRatio > 0;
if (hasEntered && entry.intersectionRatio <= 0) {

If a frame of the slide lands exactly where the content's leading edge touches the viewport's edge, the intersection has no area. The IntersectionObserver specification counts an edge-adjacent target as intersecting, so the observer reports isIntersecting: true with intersectionRatio: 0.

The next report then comes only when the threshold index changes. Ratio 0 while intersecting and any ratio between 0 and 1 have the same index, since the first threshold greater than either is 1. So nothing else is reported until the content is whole or gone.

A sheet that rests with all of its content visible recovers at ratio 1. A sheet that rests below ratio 1 does not, and hasEntered and isVisible.current stay false while it stands open on screen. Two ordinary shapes rest below 1:

  • a partial detent from snapPoints, by reading the code; I did not test this shape;
  • content partly clipped by the sheet. Ours has a drag handle above SheetContent, which pushes the content past the sheet's clipped bottom, so it rests at ratio 0.875 or 0.9375 (measured).
What the person sees
  • Desktop Safari: closing the sheet strands it. onExit sees isVisible.current false and waits for a scrollend, but nothing scrolls, so the overlay stays on the page with data-exiting. This was measured in WebKit with navigator.platform left as Win32.
  • iOS, with navigator.platform set to iPhone in WebKit: the close button still works. A swipe down, simulated with an instant scroll, leaves the sheet off-screen and open: hasEntered is false, so the ratio 0 report closes nothing, and the page stays inert until one tap anywhere closes it.
How often

The rate depends on where the frames land.

  • Without any forcing, 3 of 325 rounds stranded in Windows WebKit, in a loop that opened a sheet, picked a value in it and closed it.
  • Two of those three were caught with logging. Each log shows two reports at ratio 0, then scrollend at the rest position, then no further report. The first report is the initial one, off screen. A second report at the same ratio can only come from isIntersecting changing, which is what identifies the edge frame.

main looks likely to make this much more frequent, by reading only; I have not run it. skipOffscreenTravel from #10815, which is not in 1.22.1, moves the scroller so that "the sheet's leading edge sits at the viewport edge" before the smooth scroll starts one frame later. That is this frame, placed there on purpose. If the jump lands exactly, every sheet resting below ratio 1 would stay un-entered on every open.

Reproduction
  1. Render a bottom Sheet whose content rests below ratio 1, for example with an element above SheetContent and overflow: clip on the Sheet.
  2. Before opening it, wrap Element.prototype.scrollTo so that the first smooth call on [data-sheet-scroll] does this instead: turn snapping off, set scrollTop to content.getBoundingClientRect().top + scroller.scrollTop - window.innerHeight, wait 120 ms, restore snapping, and then make the original call. This forces the frame instead of waiting for one.
  3. Wrap IntersectionObserver to log intersectionRatio and isIntersecting for [data-sheet-content].
  4. Open the sheet. It rises and rests on screen. The log's last entry has ratio 0.
  5. In WebKit, close it with a button, or scroll [data-sheet-scroll] to 0. See the two results above.
Possible fix

These are two candidates. Neither has run against your test suite.

  1. Read entry.isIntersecting in those three expressions. We are patching 1.22.1 this way. It has one catch that does not affect us: a sheet as tall as the viewport rests at scroll 0 with its content exactly edge-adjacent, so isIntersecting would stay true at the exit position and react-aria-sheet-close would never fire. Our sheets are at most 92% of the viewport with a handle above the content, so they stop short of that.
  2. Add a small threshold, for example threshold: [0, 0.001, 1], and keep the ratio checks. The ratio leaving 0 then crosses a threshold and produces a report, and the exit check keeps working at the edge. I have not tested this one. Number.MIN_VALUE may not work, because browsers may store thresholds in lower precision.

The second looks safer to me for the general case, but you know the sheet's geometry better than I do.


Defect 2: a Select pressed while the sheet rises opens a list that goes inert

What goes wrong

SheetOverlay uses UNSTABLE_deferUntilEntered, so useModalOverlay calls ariaHideOutside([modal], {shouldUseInert: true}) only once the sheet has entered. The sheet's controls can already be pressed during the slide.

  1. A press on a Select inside the sheet opens its popover, portaled outside the modal. The popover's own ariaHideOutside([popover]) runs.
  2. The slide ends, and the modal's deferred hide runs. Its visibleNodes holds only the modal, so the walk marks the popover's portal container inert. The popover opened first, so nothing registered it with this later hide.

The order is reversed from what the hide expects: the popover is above the modal, but the modal's hide runs after it.

What the person sees

The list is on screen and looks normal, but it ignores presses. A tap on an option closes the list with no choice made. A second try works, because the sheet has entered by then.

The window is about half a second after opening. A quick person can reach it.

Reproduction
  1. Render a bottom Sheet containing a Select.
  2. Open the sheet, and press the Select's trigger while the overlay still has data-entering. I pressed in the frame the scroller passed 620 of its 681 px of travel.
  3. Wait for the slide to end. The popover's portal container or an ancestor is inert.
  4. Tap an option. The list closes and the value does not change.

This was measured in Chromium. I could not measure it in WebKit, so I do not know whether it behaves the same there.

Relation to #10814 and #10802

#10814 describes the same order of events for a ComboBox as its first part, with keepVisible registered with the combo box's own hide rather than the modal's. It was closed as a duplicate of #10802. As far as I can tell from their descriptions, #10802 and its pull requests #10812 and #10884 are about two hides sharing one element and restoring it correctly, which is the second part of #10814. The popover here is hidden by one call only, and it is the wrong one, so I do not think those fixes reach it. Please correct me if they do.

Possible fix

The deferred hide could keep visible whatever a hide opened during the deferral is keeping visible, since those overlays stand above the modal. Setting data-react-aria-top-layer on the popover also avoids it, but that attribute is an internal rather than an API, so I have not used it. I have not built the first option.

Lingua principale
TypeScript
Stelle
16k
Fork
1.6k
Merge medio
4g 10h
PR unite (30g)
42

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di adobe/react-spectrum

Tutte le issue di adobe/react-spectrum

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.