diff --git a/app/components/RankTable.module.css b/app/components/RankTable.module.css index 2a4943f52..bf078d0ee 100644 --- a/app/components/RankTable.module.css +++ b/app/components/RankTable.module.css @@ -33,8 +33,17 @@ border-radius: var(--radius-box); } + /* long boards skip laying out and painting the rows scrolled out of view; + the skipped ones must still be exactly as tall as the rendered ones or the + page jumps while scrolling, and as engines disagree on whether the + intrinsic size includes the padding, the height is pinned with a min-height + instead: an avatar row, or a weapon image row */ content-visibility: auto; - contain-intrinsic-size: auto 32px; + min-block-size: calc(24px + 2 * var(--s-2)); + + &:has(.weapon) { + min-block-size: calc(32px + 2 * var(--s-2)); + } } .dividerRow { diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 67976bb34..bfc69b631 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { X } from "lucide-react"; import * as React from "react"; -import { createPortal } from "react-dom"; +import { createPortal, flushSync } from "react-dom"; import { useNavigate } from "react-router"; import { SendouButton, @@ -199,12 +199,24 @@ function TriggeredDialog({ // React wires `onToggle` on a hydrated only when it is also a // popover, so opens are observed natively (also seeding from a dialog - // opened before hydration) - const trackOpenState = (dialog: HTMLDialogElement) => { + // opened before hydration). Lazy content is committed on `beforetoggle`, + // which fires synchronously before the dialog shows, so it is in the + // dialog's first painted frame rather than a frame behind it. Wired once + // on mount: a ref callback would rerun on every render and, on an open + // dialog, take the focus back from whatever inside it the user is typing in. + React.useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const handleOpened = () => { setOpen(true); dialog.focus(); }; + const onBeforeToggle = (event: Event) => { + if ((event as ToggleEvent).newState === "open") { + flushSync(() => setOpen(true)); + } + }; const onToggle = (event: Event) => { if ((event as ToggleEvent).newState === "open") { handleOpened(); @@ -212,10 +224,14 @@ function TriggeredDialog({ setOpen(false); } }; + dialog.addEventListener("beforetoggle", onBeforeToggle); dialog.addEventListener("toggle", onToggle); if (dialog.open) handleOpened(); - return () => dialog.removeEventListener("toggle", onToggle); - }; + return () => { + dialog.removeEventListener("beforetoggle", onBeforeToggle); + dialog.removeEventListener("toggle", onToggle); + }; + }, []); return ( <> @@ -230,10 +246,7 @@ function TriggeredDialog({ }, })} { - dialogRef.current = dialog; - return dialog ? trackOpenState(dialog) : undefined; - }} + ref={dialogRef} id={dialogId} {...dialogElementProps(chrome, dialogId, handleClosed)} > diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 40a5a5e69..818b78bd1 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -9,7 +9,12 @@ import { import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; -import { focusLeftTo, isOwnToggle, useAnchorSafeId } from "./Popover"; +import { + focusLeftTo, + isOwnToggle, + useAnchorSafeId, + useShowPopoverOnOpen, +} from "./Popover"; import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; type MenuPlacement = "bottom start" | "bottom end" | "bottom right"; @@ -51,6 +56,11 @@ export function SendouMenu({ if (popoverRef.current?.matches(":popover-open")) setOpen(true); }, []); + const onBeforeToggle = useShowPopoverOnOpen({ + popoverRef, + open, + onOpen: () => setOpen(true), + }); useCloseOnScrollClip(open, popoverRef, () => popoverRef.current?.hidePopover(), ); @@ -68,11 +78,11 @@ export function SendouMenu({ if (!isOwnToggle(event)) return; const next = event.newState === "open"; - if (next === open) return; - setOpen(next); - if (next) { - requestAnimationFrame(() => popoverRef.current?.focus()); + popoverRef.current?.focus(); + } + if (next !== open) { + setOpen(next); } }; @@ -124,6 +134,7 @@ export function SendouMenu({ })} style={{ positionAnchor: anchorName } as React.CSSProperties} data-placement={placement} + onBeforeToggle={onBeforeToggle} onToggle={onToggle} onKeyDown={onKeyDown} onBlur={onBlur} diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index 7814bd3ef..08666c353 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; import * as React from "react"; +import { flushSync } from "react-dom"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type AnchorPlacement, useAnchorPositioning, @@ -23,6 +25,50 @@ export function isOwnToggle(event: React.ToggleEvent) { return event.target === event.currentTarget; } +/** + * Shows a popover once React has committed `open`, so content mounted only + * while open is in the popover's first painted frame instead of appearing a + * frame after it. Returns the `beforetoggle` handler for the popover: the + * browser's own open (the trigger's `popoverTarget`) is cancelled there and + * redone through `onOpen` in the next frame, still before it paints, as a + * popover cannot be shown from inside the show operation being cancelled. + * Call it before `useAnchorPositioning` so the popover is showing by the time + * that measures it. + */ +export function useShowPopoverOnOpen({ + popoverRef, + open, + onOpen, +}: { + popoverRef: React.RefObject; + open: boolean; + onOpen: () => void; +}) { + const showingRef = React.useRef(false); + const onOpenRef = React.useRef(onOpen); + onOpenRef.current = onOpen; + + useIsomorphicLayoutEffect(() => { + const popover = popoverRef.current; + if (!open || !popover || popover.matches(":popover-open")) return; + showingRef.current = true; + popover.showPopover(); + showingRef.current = false; + }, [open, popoverRef]); + + return (event: React.ToggleEvent) => { + if ( + !isOwnToggle(event) || + event.newState !== "open" || + showingRef.current + ) { + return; + } + event.preventDefault(); + requestAnimationFrame(() => flushSync(() => onOpenRef.current())); + }; +} + /** * Whether a focusout moved focus to an element outside all of `containers`, * so an overlay tabbed out of can close. Focus lost to nowhere (a click on a @@ -95,13 +141,16 @@ export function SendouPopover({ setOpenRef.current(true); return; } - if (open) { - popover.showPopover(); - } else { + if (!open) { popover.hidePopover(); } }, [open]); + const onBeforeToggle = useShowPopoverOnOpen({ + popoverRef, + open, + onOpen: () => setOpen(true), + }); useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); useAnchorPositioning({ isOpen: open, @@ -155,6 +204,7 @@ export function SendouPopover({ role="dialog" tabIndex={-1} data-placement={placement} + onBeforeToggle={onBeforeToggle} onToggle={onToggle} onBlur={onBlur} > @@ -183,7 +233,8 @@ export function SendouAnchoredPopover({ const popoverRef = React.useRef(null); - React.useEffect(() => { + // before the positioning effect, so the content is placed by its first paint + useIsomorphicLayoutEffect(() => { const trigger = triggerRef.current; const popover = popoverRef.current; if (!popover) return; diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 895813085..fb7f543cc 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -1,7 +1,6 @@ import clsx from "clsx"; import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; -import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; @@ -9,7 +8,12 @@ import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; -import { focusLeftTo, isOwnToggle, useAnchorSafeId } from "./Popover"; +import { + focusLeftTo, + isOwnToggle, + useAnchorSafeId, + useShowPopoverOnOpen, +} from "./Popover"; import styles from "./Select.module.css"; import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; @@ -168,8 +172,6 @@ export function SendouSelect({ }; const [open, setOpenState] = React.useState(false); - /** Set while the layout effect below shows the popover, so the beforetoggle handler lets that one through. */ - const showingRef = React.useRef(false); const [, rerenderWithRegisteredContent] = React.useReducer( (count: number) => count + 1, 0, @@ -194,15 +196,11 @@ export function SendouSelect({ const listboxRef = React.useRef(null); const searchInputRef = React.useRef(null); - // shown only once the options and search field are committed, and before - // the positioning effect measures it, so the first paint is the final one - useIsomorphicLayoutEffect(() => { - const popover = popoverRef.current; - if (!open || !popover || popover.matches(":popover-open")) return; - showingRef.current = true; - popover.showPopover(); - showingRef.current = false; - }, [open]); + const onPopoverBeforeToggle = useShowPopoverOnOpen({ + popoverRef, + open, + onOpen: () => setOpen(true), + }); useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); useAnchorPositioning({ isOpen: open, @@ -367,24 +365,6 @@ export function SendouSelect({ } }; - /** - * The trigger's popoverTarget opens the popover before the options exist: - * mount them first and let the layout effect show it. That happens in the - * next frame, still before it paints, as showing a popover from inside the - * show operation being cancelled here is an error. - */ - const onPopoverBeforeToggle = (event: React.ToggleEvent) => { - if ( - !isOwnToggle(event) || - event.newState !== "open" || - showingRef.current - ) { - return; - } - event.preventDefault(); - requestAnimationFrame(() => flushSync(() => setOpen(true))); - }; - const onPopoverToggle = (event: React.ToggleEvent) => { if (!isOwnToggle(event) || event.newState === "open" || !open) return; diff --git a/changelog/2026-09-16-popover-open-flash.md b/changelog/2026-09-16-popover-open-flash.md new file mode 100644 index 000000000..5c5b8ab03 --- /dev/null +++ b/changelog/2026-09-16-popover-open-flash.md @@ -0,0 +1,8 @@ +--- +type: bug +--- +Popover and dialog polish + +- Weapon selects, popovers, menus and image export dialogs no longer flash empty for a frame when opened +- Weapon select lists and leaderboards no longer jump while scrolling +- A search field inside a dialog no longer loses focus (and closes its results) while you type diff --git a/changelog/2026-09-16-weapon-select-open-flash.md b/changelog/2026-09-16-weapon-select-open-flash.md deleted file mode 100644 index 713a7d2c5..000000000 --- a/changelog/2026-09-16-weapon-select-open-flash.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -type: bug ---- -Fix weapon select popover flashing empty for a frame when opened and the list jumping while scrolling