fix(dashboard): show skeleton instead of default layout until hydration completes

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 10:20:50 +02:00
parent 24435a1824
commit a16c41e739
3 changed files with 69 additions and 10 deletions
+3
View File
@@ -59,6 +59,9 @@ export default function SignInPage() {
setTotp(""); setTotp("");
} }
} else { } 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"); router.push("/dashboard");
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types"; import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types";
import { noCompactor } from "react-grid-layout";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Suspense, useState, useRef, useEffect, useMemo } from "react"; import { Suspense, useState, useRef, useEffect, useMemo } from "react";
import { useDashboardLayout } from "~/hooks/useDashboardLayout.js"; import { useDashboardLayout } from "~/hooks/useDashboardLayout.js";
@@ -145,7 +146,7 @@ function DeferredWidgetBody({
export function DashboardClient() { export function DashboardClient() {
const [addModalOpen, setAddModalOpen] = useState(false); const [addModalOpen, setAddModalOpen] = useState(false);
const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = const { config, isHydrated, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
useDashboardLayout(); useDashboardLayout();
// Measure grid container width so Responsive knows the column size. // Measure grid container width so Responsive knows the column size.
@@ -226,6 +227,26 @@ export function DashboardClient() {
[config.widgets, removeWidget, updateWidgetConfig], [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 (
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">Dashboard</h1>
<p className="app-page-subtitle mt-1">
Drag widgets to rearrange them and resize from the corners.
</p>
</div>
</div>
<div className="app-surface overflow-hidden p-3">
<GridLayoutSkeleton />
</div>
</div>
);
}
return ( return (
<div className="app-page space-y-6"> <div className="app-page space-y-6">
<div className="app-page-header gap-4"> <div className="app-page-header gap-4">
@@ -291,8 +312,7 @@ export function DashboardClient() {
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={80} rowHeight={80}
compactType={null} compactor={noCompactor}
preventCollision={false}
onLayoutChange={( onLayoutChange={(
_: unknown, _: unknown,
allLayouts: Record< allLayouts: Record<
+43 -7
View File
@@ -60,10 +60,15 @@ export function useDashboardLayout() {
// Initial state: load from user-scoped localStorage once we have the userId. // 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. // Before userId resolves, fall back to the default layout so the page is not blank.
const [config, setConfig] = useState<DashboardLayoutConfig>(createDefaultDashboardLayout); const [config, setConfig] = useState<DashboardLayoutConfig>(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<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasHydratedFromDbRef = useRef(false); const hasHydratedFromDbRef = useRef(false);
const hasLocalChangesBeforeHydrationRef = useRef(false); const hasLocalChangesBeforeHydrationRef = useRef(false);
const hasHydratedFromStorageRef = useRef(false); const hasHydratedFromStorageRef = useRef(false);
// Holds the DB config that needs to be persisted to localStorage once userId is available.
const pendingStorageSaveRef = useRef<ReturnType<typeof normalizeDashboardLayout> | null>(null);
// Once userId is known, hydrate from user-scoped localStorage (if no DB data yet). // Once userId is known, hydrate from user-scoped localStorage (if no DB data yet).
useEffect(() => { useEffect(() => {
@@ -71,12 +76,12 @@ export function useDashboardLayout() {
return; return;
} }
const stored = loadFromStorage(userId); const stored = loadFromStorage(userId);
hasHydratedFromStorageRef.current = true;
if (stored) { if (stored) {
hasHydratedFromStorageRef.current = true;
setConfig(stored); setConfig(stored);
} else {
hasHydratedFromStorageRef.current = true;
} }
// localStorage check is complete — show whatever we have now.
setIsHydrated(true);
}, [userId]); }, [userId]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -86,10 +91,19 @@ export function useDashboardLayout() {
const saveMutation = trpc.user.saveDashboardLayout.useMutation(); 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(() => { useEffect(() => {
// Wait until the query has settled (undefined = still in-flight).
if (dbData === undefined || hasHydratedFromDbRef.current) return;
const remoteLayout = dbData?.layout ?? null; 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; return;
} }
@@ -98,16 +112,31 @@ export function useDashboardLayout() {
hasHydratedFromDb: hasHydratedFromDbRef.current, hasHydratedFromDb: hasHydratedFromDbRef.current,
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current, hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
})) { })) {
// DB data present but local changes already in-flight — keep local state, mark done.
hasHydratedFromDbRef.current = true; hasHydratedFromDbRef.current = true;
setIsHydrated(true);
return; return;
} }
const dbConfig = normalizeDashboardLayout(remoteLayout); const dbConfig = normalizeDashboardLayout(remoteLayout);
hasHydratedFromDbRef.current = true; hasHydratedFromDbRef.current = true;
setConfig(dbConfig); 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]); }, [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(() => () => { useEffect(() => () => {
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(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 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 }; const newConfig = { ...prev, widgets: updatedWidgets };
if (changed) persist(newConfig); persist(newConfig);
return newConfig; return newConfig;
}); });
}, },
@@ -201,6 +236,7 @@ export function useDashboardLayout() {
return { return {
config, config,
isHydrated,
addWidget, addWidget,
removeWidget, removeWidget,
updateWidgetConfig, updateWidgetConfig,