fix: eliminate all loading flashes in sidebar site picker
Root cause 1: hydration mismatch — SSR rendered collapsed=true but client useState initializer read localStorage synchronously, causing an immediate state change and visual flash. Fix: always initialize collapsed=true, read localStorage in useEffect so the transition is smooth (collapsed→expanded animates cleanly). Root cause 2: three-phase badge rendering (skeleton→letter→favicon) caused visible state changes. Fix: just show the empty orange badge until the favicon arrives. No skeleton, no letter fallback. One state transition: empty→favicon.
This commit is contained in:
@@ -86,7 +86,6 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentSite = sites.find((s) => s.id === siteId)
|
const currentSite = sites.find((s) => s.id === siteId)
|
||||||
const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?'
|
|
||||||
const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null
|
const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -129,23 +128,19 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
|
|||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 overflow-hidden"
|
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 overflow-hidden"
|
||||||
>
|
>
|
||||||
<span className="w-7 h-7 rounded-md bg-brand-orange/10 text-brand-orange flex items-center justify-center text-xs font-bold shrink-0 overflow-hidden">
|
<span className="w-7 h-7 rounded-md bg-brand-orange/10 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
{faviconUrl && !faviconFailed ? (
|
{faviconUrl && !faviconFailed && (
|
||||||
<img
|
<img
|
||||||
src={faviconUrl}
|
src={faviconUrl}
|
||||||
alt={currentSite?.name || ''}
|
alt={currentSite?.name || ''}
|
||||||
className="w-5 h-5 object-contain"
|
className="w-5 h-5 object-contain"
|
||||||
onError={() => setFaviconFailed(true)}
|
onError={() => setFaviconFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : currentSite ? (
|
|
||||||
initial
|
|
||||||
) : (
|
|
||||||
<span className="w-4 h-4 rounded bg-brand-orange/20 animate-pulse" />
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Label collapsed={collapsed}>
|
<Label collapsed={collapsed}>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="truncate">{currentSite?.name || <span className="w-16 h-4 rounded bg-neutral-700/20 animate-pulse inline-block" />}</span>
|
<span className="truncate">{currentSite?.name || ''}</span>
|
||||||
<ChevronUpDownIcon className="w-4 h-4 text-neutral-400 shrink-0" />
|
<ChevronUpDownIcon className="w-4 h-4 text-neutral-400 shrink-0" />
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -245,10 +240,13 @@ export default function Sidebar({
|
|||||||
const [sites, setSites] = useState<Site[]>([])
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
const [pendingHref, setPendingHref] = useState<string | null>(null)
|
const [pendingHref, setPendingHref] = useState<string | null>(null)
|
||||||
const wasCollapsedRef = useRef(false)
|
const wasCollapsedRef = useRef(false)
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(true) // Always start collapsed — no hydration mismatch
|
||||||
if (typeof window === 'undefined') return true // SSR: default collapsed to avoid hydration flash
|
|
||||||
return localStorage.getItem(SIDEBAR_KEY) !== 'false' // default collapsed unless explicitly set to false
|
// Read saved state after mount — expands smoothly if user had it open
|
||||||
})
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(SIDEBAR_KEY)
|
||||||
|
if (saved === 'false') setCollapsed(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
|
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
|
||||||
useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose])
|
useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose])
|
||||||
|
|||||||
Reference in New Issue
Block a user