fix(app): prevent desktop combobox initial 0,0 flash

This commit is contained in:
Mohamed Boudra
2026-02-16 18:37:04 +07:00
parent f590e60617
commit 427e3daca9
2 changed files with 183 additions and 4 deletions
@@ -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<HTMLElement>("*"))];
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);
});
+53 -4
View File
@@ -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<View | null>;
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 (
<Animated.View
@@ -171,6 +190,7 @@ export function Combobox({
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
@@ -180,6 +200,7 @@ export function Combobox({
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-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({
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={[
styles.desktopContainer,
{
@@ -524,7 +573,7 @@ export function Combobox({
maxWidth: 400,
},
floatingStyles,
referenceWidth === null ? { opacity: 0 } : null,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
]}
ref={refs.setFloating}