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,