From a16c41e739e63ae80ac7fe99fe8374a77ef0c2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 10:20:50 +0200 Subject: [PATCH] fix(dashboard): show skeleton instead of default layout until hydration completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: useDashboardLayout initialised React state with createDefaultDashboardLayout() (1 widget), so the wrong default rendered during the ~100–500ms window while React Query fetched the user session and DB layout after login. On reload within staleTime the cache hit resolved instantly, masking the bug. Fix: add isHydrated boolean state that becomes true only once localStorage OR DB hydration has settled; DashboardClient renders a GridLayoutSkeleton until then. Also adds router.refresh() in the sign-in handler to bust the Next.js Router Cache so the post-login navigation always lands on a fresh server component tree. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/auth/signin/page.tsx | 3 ++ .../components/dashboard/DashboardClient.tsx | 26 ++++++++-- apps/web/src/hooks/useDashboardLayout.ts | 50 ++++++++++++++++--- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index a481cbd..9a3d991 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -59,6 +59,9 @@ export default function SignInPage() { setTotp(""); } } else { + // Invalidate the Next.js Router Cache so (app)/layout.tsx re-renders + // with the fresh session, then navigate to the dashboard. + router.refresh(); router.push("/dashboard"); } diff --git a/apps/web/src/components/dashboard/DashboardClient.tsx b/apps/web/src/components/dashboard/DashboardClient.tsx index cf988de..019c405 100644 --- a/apps/web/src/components/dashboard/DashboardClient.tsx +++ b/apps/web/src/components/dashboard/DashboardClient.tsx @@ -1,6 +1,7 @@ "use client"; import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types"; +import { noCompactor } from "react-grid-layout"; import dynamic from "next/dynamic"; import { Suspense, useState, useRef, useEffect, useMemo } from "react"; import { useDashboardLayout } from "~/hooks/useDashboardLayout.js"; @@ -145,7 +146,7 @@ function DeferredWidgetBody({ export function DashboardClient() { const [addModalOpen, setAddModalOpen] = useState(false); - const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = + const { config, isHydrated, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = useDashboardLayout(); // Measure grid container width so Responsive knows the column size. @@ -226,6 +227,26 @@ export function DashboardClient() { [config.widgets, removeWidget, updateWidgetConfig], ); + // Show a skeleton while hydration is in-flight (avoids flashing the 1-widget default + // layout before the user's real layout is loaded from localStorage or the DB). + if (!isHydrated) { + return ( +
+
+
+

Dashboard

+

+ Drag widgets to rearrange them and resize from the corners. +

+
+
+
+ +
+
+ ); + } + return (
@@ -291,8 +312,7 @@ export function DashboardClient() { breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} rowHeight={80} - compactType={null} - preventCollision={false} + compactor={noCompactor} onLayoutChange={( _: unknown, allLayouts: Record< diff --git a/apps/web/src/hooks/useDashboardLayout.ts b/apps/web/src/hooks/useDashboardLayout.ts index cefc528..d515a31 100644 --- a/apps/web/src/hooks/useDashboardLayout.ts +++ b/apps/web/src/hooks/useDashboardLayout.ts @@ -60,10 +60,15 @@ export function useDashboardLayout() { // Initial state: load from user-scoped localStorage once we have the userId. // Before userId resolves, fall back to the default layout so the page is not blank. const [config, setConfig] = useState(createDefaultDashboardLayout); + // isHydrated: true once we have applied the best available layout (localStorage or DB). + // While false, callers should show a skeleton instead of the default 1-widget layout. + const [isHydrated, setIsHydrated] = useState(false); const saveTimeoutRef = useRef | null>(null); const hasHydratedFromDbRef = useRef(false); const hasLocalChangesBeforeHydrationRef = useRef(false); const hasHydratedFromStorageRef = useRef(false); + // Holds the DB config that needs to be persisted to localStorage once userId is available. + const pendingStorageSaveRef = useRef | null>(null); // Once userId is known, hydrate from user-scoped localStorage (if no DB data yet). useEffect(() => { @@ -71,12 +76,12 @@ export function useDashboardLayout() { return; } const stored = loadFromStorage(userId); + hasHydratedFromStorageRef.current = true; if (stored) { - hasHydratedFromStorageRef.current = true; setConfig(stored); - } else { - hasHydratedFromStorageRef.current = true; } + // localStorage check is complete — show whatever we have now. + setIsHydrated(true); }, [userId]); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -86,10 +91,19 @@ export function useDashboardLayout() { const saveMutation = trpc.user.saveDashboardLayout.useMutation(); - // Sync from DB on load (DB wins if it has data) + // Sync from DB on load (DB wins if it has data). useEffect(() => { + // Wait until the query has settled (undefined = still in-flight). + if (dbData === undefined || hasHydratedFromDbRef.current) return; + const remoteLayout = dbData?.layout ?? null; - if (remoteLayout === null || hasHydratedFromDbRef.current) { + + if (remoteLayout === null) { + // DB has no saved layout — don't block localStorage hydration. + // If localStorage already ran, we're done; otherwise localStorage will call setIsHydrated. + if (hasHydratedFromStorageRef.current) { + setIsHydrated(true); + } return; } @@ -98,16 +112,31 @@ export function useDashboardLayout() { hasHydratedFromDb: hasHydratedFromDbRef.current, hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current, })) { + // DB data present but local changes already in-flight — keep local state, mark done. hasHydratedFromDbRef.current = true; + setIsHydrated(true); return; } const dbConfig = normalizeDashboardLayout(remoteLayout); hasHydratedFromDbRef.current = true; setConfig(dbConfig); - if (userId) saveToStorage(userId, dbConfig); + setIsHydrated(true); + if (userId) { + saveToStorage(userId, dbConfig); + } else { + // userId not yet resolved — defer the localStorage write until it is. + pendingStorageSaveRef.current = dbConfig; + } }, [dbData, userId]); + // When userId becomes available, flush any pending localStorage save from DB hydration. + useEffect(() => { + if (!userId || !pendingStorageSaveRef.current) return; + saveToStorage(userId, pendingStorageSaveRef.current); + pendingStorageSaveRef.current = null; + }, [userId]); + useEffect(() => () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -185,8 +214,14 @@ export function useDashboardLayout() { return orig && (w.x !== orig.x || w.y !== orig.y || w.w !== orig.w || w.h !== orig.h); }); + // Return the same reference when nothing changed so React skips + // the re-render. Without this guard, setConfig always produces a new + // object → re-render → new layouts prop → react-grid-layout fires + // onLayoutChange again → infinite oscillation loop. + if (!changed) return prev; + const newConfig = { ...prev, widgets: updatedWidgets }; - if (changed) persist(newConfig); + persist(newConfig); return newConfig; }); }, @@ -201,6 +236,7 @@ export function useDashboardLayout() { return { config, + isHydrated, addWidget, removeWidget, updateWidgetConfig,