From 8062e0e2c1fa4c68bd3e255dd6717411e2c7ecaf Mon Sep 17 00:00:00 2001 From: Ares90125 Date: Thu, 14 May 2026 07:27:05 -0700 Subject: [PATCH] fix(ui): eliminate sidebar Filters flash on tab switch --- src/components/issues/IssuesList.tsx | 29 +++++++++++++++---- src/components/leaderboard/TopMinersTable.tsx | 22 +++++++++++--- src/pages/WatchlistPage.tsx | 24 ++++++++++++--- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/components/issues/IssuesList.tsx b/src/components/issues/IssuesList.tsx index a2874ca7..9235cd3e 100644 --- a/src/components/issues/IssuesList.tsx +++ b/src/components/issues/IssuesList.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react'; import { DebouncedSearchInput, useDebouncedSearchDraft, @@ -272,10 +278,23 @@ const IssuesList: React.FC = ({ ); const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); - const [portalTarget, setPortalTarget] = useState(null); - useEffect(() => { - setPortalTarget(document.getElementById('tabs-options-portal')); - }, []); + // Seed synchronously so tab/filter re-mounts don't flash an empty Filters + // slot in the sidebar between the first render and the post-paint effect. + // useLayoutEffect handles the rare initial-mount miss before paint. + const [portalTarget, setPortalTarget] = useState(() => + typeof document === 'undefined' + ? null + : document.getElementById('tabs-options-portal'), + ); + // Always validate (don't just skip when set) — on cross-page navigation + // the useState initializer runs while the previous page's + // #tabs-options-portal is still attached, so portalTarget can be a stale + // node that gets removed at commit. Re-resolve before paint. + useLayoutEffect(() => { + const current = document.getElementById('tabs-options-portal'); + if (current === portalTarget) return; + setPortalTarget(current); + }, [portalTarget]); const [optionsAnchorEl, setOptionsAnchorEl] = useState( null, diff --git a/src/components/leaderboard/TopMinersTable.tsx b/src/components/leaderboard/TopMinersTable.tsx index e1d20cc9..19773e4c 100644 --- a/src/components/leaderboard/TopMinersTable.tsx +++ b/src/components/leaderboard/TopMinersTable.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useState, useRef, @@ -378,7 +379,14 @@ const TopMinersTable: React.FC = ({ }, [filteredMiners.length, visibleCount, setVisibleCount]); const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); - const [portalTarget, setPortalTarget] = useState(null); + // Seed synchronously so leaderboard timeline/filter switches don't flash + // an empty Filters slot in the sidebar. useLayoutEffect catches the rare + // initial-mount miss before paint. + const [portalTarget, setPortalTarget] = useState(() => + typeof document === 'undefined' + ? null + : document.getElementById('tabs-options-portal'), + ); const observerTarget = useRef(null); const [stackedLayoutPage, setStackedLayoutPage] = useState(0); @@ -441,9 +449,15 @@ const TopMinersTable: React.FC = ({ /> ) : null; - useEffect(() => { - setPortalTarget(document.getElementById('tabs-options-portal')); - }, []); + // Always validate (don't just skip when set) — on cross-page navigation + // the useState initializer runs while the previous page's + // #tabs-options-portal is still attached, so portalTarget can be a stale + // node that gets removed at commit. Re-resolve before paint. + useLayoutEffect(() => { + const current = document.getElementById('tabs-options-portal'); + if (current === portalTarget) return; + setPortalTarget(current); + }, [portalTarget]); useEffect(() => { if (!isLargeScreen || !observerTarget.current || remainingMiners <= 0) { diff --git a/src/pages/WatchlistPage.tsx b/src/pages/WatchlistPage.tsx index 43590754..f5e1793f 100644 --- a/src/pages/WatchlistPage.tsx +++ b/src/pages/WatchlistPage.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useState, useRef, @@ -572,12 +573,27 @@ const OptionsLabel: React.FC<{ children: React.ReactNode }> = ({ /* ─── WatchlistPortal: sidebar panel on xl, popover button otherwise ─── */ const WatchlistPortal: React.FC = (props) => { - const [target, setTarget] = useState(null); + // Seed from the DOM synchronously so on tab switches (where the parent's + // #tabs-options-portal Box is already in the document) the first render + // already returns the Portal — no intermediate "fallback" paint, no flash + // of an empty Filters slot in the sidebar. + const [target, setTarget] = useState(() => + typeof document === 'undefined' + ? null + : document.getElementById('tabs-options-portal'), + ); const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); - useEffect(() => { - setTarget(document.getElementById('tabs-options-portal')); - }, []); + // Always validate (don't just skip when set) — on cross-page navigation + // the useState initializer runs while the OLD page's #tabs-options-portal + // is still attached, so `target` can be a stale node that gets removed at + // commit. useLayoutEffect re-resolves before paint and replaces the + // stale node with the current page's portal target. + useLayoutEffect(() => { + const current = document.getElementById('tabs-options-portal'); + if (current === target) return; + setTarget(current); + }, [target]); if (target && isLargeScreen) { return (