From 427e3daca94adfd47796458ae8cd435c8f49ddc8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 16 Feb 2026 18:37:04 +0700 Subject: [PATCH] fix(app): prevent desktop combobox initial 0,0 flash --- .../e2e/sidebar-project-filter-flash.spec.ts | 130 ++++++++++++++++++ packages/app/src/components/ui/combobox.tsx | 57 +++++++- 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 packages/app/e2e/sidebar-project-filter-flash.spec.ts diff --git a/packages/app/e2e/sidebar-project-filter-flash.spec.ts b/packages/app/e2e/sidebar-project-filter-flash.spec.ts new file mode 100644 index 000000000..5d1e2c237 --- /dev/null +++ b/packages/app/e2e/sidebar-project-filter-flash.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "./fixtures"; +import { gotoHome } from "./helpers/app"; + +test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => { + await gotoHome(page); + + const trigger = page.getByText("Project", { exact: true }).first(); + await expect(trigger).toBeVisible(); + + await page.evaluate(() => { + (window as any).__projectFilterFlashProbe = new Promise<{ + targetFound: boolean; + visibleAtOrigin: boolean; + records: Array<{ left: number; top: number; opacity: number }>; + }>((resolve) => { + let target: HTMLElement | null = null; + const records: Array<{ left: number; top: number; opacity: number }> = []; + + const capture = () => { + if (!target) return; + const style = getComputedStyle(target); + records.push({ + left: Number.parseFloat(style.left || "0"), + top: Number.parseFloat(style.top || "0"), + opacity: Number.parseFloat(style.opacity || "1"), + }); + }; + + const getContainer = (node: HTMLElement): HTMLElement | null => { + let current: HTMLElement | null = node; + while (current && current !== document.body) { + const style = getComputedStyle(current); + if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") { + return current; + } + current = current.parentElement; + } + return null; + }; + + const tryResolveTarget = (root: HTMLElement) => { + const stack = [root, ...Array.from(root.querySelectorAll("*"))]; + for (const element of stack) { + if (!element.textContent?.includes("No projects")) continue; + const container = getContainer(element); + if (!container) continue; + target = container; + return true; + } + return false; + }; + + const finish = () => { + observer.disconnect(); + if (!target) { + resolve({ + targetFound: false, + visibleAtOrigin: false, + records: [], + }); + return; + } + + const visibleAtOrigin = records.some( + (entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01 + ); + + resolve({ + targetFound: true, + visibleAtOrigin, + records, + }); + }; + + const sampleFrames = () => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + finish(); + }); + }); + }); + }); + }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const added of Array.from(mutation.addedNodes)) { + if (!(added instanceof HTMLElement)) continue; + if (tryResolveTarget(added)) { + sampleFrames(); + return; + } + } + + if ( + mutation.type === "attributes" && + mutation.target instanceof HTMLElement && + !target && + tryResolveTarget(mutation.target) + ) { + sampleFrames(); + return; + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style"], + }); + + setTimeout(() => finish(), 2500); + }); + }); + + await trigger.click(); + const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe); + + expect(probe.targetFound).toBe(true); + expect(probe.visibleAtOrigin).toBe(false); +}); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 2d4735466..ba2cb33f8 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -47,10 +47,29 @@ export interface ComboboxProps { open?: boolean; onOpenChange?: (open: boolean) => void; desktopPlacement?: "top-start" | "bottom-start"; + /** + * Prevents an initial frame at 0,0 by hiding desktop content until floating + * coordinates resolve. This intentionally disables fade enter/exit animation + * for that combobox instance to avoid animation overriding hidden opacity. + */ + desktopPreventInitialFlash?: boolean; anchorRef: React.RefObject; children?: ReactNode; } +function toNumericStyleValue(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { return ( ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [referenceWidth, setReferenceWidth] = useState(null); + const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); @@ -263,10 +284,38 @@ export function Combobox({ setReferenceWidth(null); return; } - const raf = requestAnimationFrame(() => update()); + const raf = requestAnimationFrame(() => void update()); return () => cancelAnimationFrame(raf); }, [desktopPlacement, isMobile, update, isOpen]); + useEffect(() => { + if (!isOpen || isMobile) { + setReferenceAtOrigin(false); + return; + } + + const referenceEl = anchorRef.current; + if (!referenceEl) { + setReferenceAtOrigin(false); + return; + } + + referenceEl.measureInWindow((x, y) => { + setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); + }); + }, [anchorRef, isMobile, isOpen]); + + const floatingTop = toNumericStyleValue(floatingStyles.top); + const floatingLeft = toNumericStyleValue(floatingStyles.left); + const hasResolvedDesktopPosition = + referenceWidth !== null && + floatingTop !== null && + floatingLeft !== null && + (floatingTop !== 0 || floatingLeft !== 0 || referenceAtOrigin); + const shouldHideDesktopContent = + desktopPreventInitialFlash && !hasResolvedDesktopPosition; + const shouldUseDesktopFade = !desktopPreventInitialFlash; + useEffect(() => { if (!isMobile) return; if (isOpen) { @@ -514,8 +563,8 @@ export function Combobox({