From 46256cddba04909aa58c806fcdd5d32b39da3101 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:23:05 +0200 Subject: [PATCH] fix: position portalled popovers before first paint to stop layout flash on open --- components/layout/account-switcher.tsx | 12 +++++++++--- components/layout/navigation-rail.tsx | 20 +++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 97ae3a7e2..6d11707e8 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect, useCallback, useMemo, useId } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, useId } from "react"; import { createPortal } from "react-dom"; import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -97,9 +97,15 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher } }, [variant]); + // Position before the first paint - the portalled popover would otherwise + // render one frame as an unpositioned block at the end of , shifting + // the page layout for a split second. + useLayoutEffect(() => { + if (open) updatePosition(); + }, [open, updatePosition]); + useEffect(() => { if (!open) return; - updatePosition(); const handleClickOutside = (e: MouseEvent) => { if ( buttonRef.current?.contains(e.target as Node) || @@ -116,7 +122,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; - }, [open, updatePosition, popoverRef]); + }, [open, popoverRef]); const handleSwitch = async (accountId: string) => { if (accountId === activeAccountId) return; diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 3df41ca87..56458b33e 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react"; import { AccountSwitcher } from "./account-switcher"; @@ -87,9 +87,15 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to ); }, []); + // Position before the first paint - the portalled popover would otherwise + // render one frame as an unpositioned block at the end of , shifting + // the page layout for a split second. + useLayoutEffect(() => { + if (open) updatePosition(); + }, [open, updatePosition]); + useEffect(() => { if (!open) return; - updatePosition(); const handleClick = (e: MouseEvent) => { if ( buttonRef.current?.contains(e.target as Node) || @@ -99,7 +105,7 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); - }, [open, updatePosition]); + }, [open]); // Usage can legitimately exceed the quota (e.g. limit lowered after the fact) const free = Math.max(0, quota.total - quota.used); @@ -250,9 +256,13 @@ export function NavigationRail({ ); }, []); + // Position before the first paint (same reasoning as the quota popover above). + useLayoutEffect(() => { + if (logoutMenuOpen) updateLogoutPosition(); + }, [logoutMenuOpen, updateLogoutPosition]); + useEffect(() => { if (!logoutMenuOpen) return; - updateLogoutPosition(); const handleClickOutside = (e: MouseEvent) => { if ( logoutBtnRef.current?.contains(e.target as Node) || @@ -269,7 +279,7 @@ export function NavigationRail({ document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; - }, [logoutMenuOpen, updateLogoutPosition, logoutPopoverRef]); + }, [logoutMenuOpen, logoutPopoverRef]); useEffect(() => { let cancelled = false;