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:
@@ -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<
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user