fix: position portalled popovers before first paint to stop layout flash on open

This commit is contained in:
Linus Rath
2026-08-20 14:23:05 +02:00
parent 54d7fb6fc9
commit 46256cddba
2 changed files with 24 additions and 8 deletions
+9 -3
View File
@@ -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 <body>, 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;
+15 -5
View File
@@ -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 <body>, 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;