Skip to content

Commit 8679ba9

Browse files
committed
perf(dashboard): defer drag writes to dragEnd, add layout containment
- Replace per-dragOver store writes with local ref+state in useDashboardDnd: snapshots cardOrders on dragStart, updates local state only during drag, commits a single moveCard on dragEnd — eliminates cascading re-renders and localStorage writes on every drag event - Thread handleDragStart and activeCardOrders through the controller so SortableContext renders from local drag state during a drag, store state when idle - Add contain:layout_style to DashboardCardItem in view mode to reduce cross-card style recalculation during scroll
1 parent 34a5bfc commit 8679ba9

6 files changed

Lines changed: 80 additions & 14 deletions

File tree

design-system/FEATURES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,10 @@ Theme system uses CSS custom properties defined in `/src/styles/theme.css`:
494494
- CSS variables for dynamic color changes
495495
- Memoized components prevent unnecessary re-renders
496496

497+
### Dashboard Drag & Drop
498+
- Drag reordering uses local state during the drag — the global store is written only once on drop, eliminating per-event re-renders and localStorage writes
499+
- `contain: layout style` on card wrappers reduces cross-card style recalculation during scroll
500+
497501
### Navigation
498502
- Section-based code splitting (future enhancement)
499503
- Lazy loading of section components

docs/DOCKER_HOME_ASSISTANT_ADDON.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ The current dashboard build includes a few runtime-focused optimizations:
150150
- Deferred rendering for offscreen room groups in the All view
151151
- Zustand-backed search result state to reduce context fan-out
152152
- Stable device-map reuse to avoid rerendering unchanged cards
153+
- Drag reordering uses local state during a drag; the global store is written once on drop, eliminating per-event re-renders and localStorage writes
153154
- Onboarding-based dashboard visibility with add/remove entity curation
154155
- Local dashboard config YAML export/import for layout and preference backup, including first-run import from onboarding
155156
- Configurable entity card interaction styles with a live preview in Settings

src/app/features/dashboard/components/dashboard-card-item.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ export const DashboardCardItem = memo(function DashboardCardItem({
8989
);
9090

9191
if (!isEditMode) {
92-
return <div className={`h-full relative ${spanClass}`}>{cardContent}</div>;
92+
return (
93+
<div className={`relative h-full [contain:layout_style] ${spanClass}`}>{cardContent}</div>
94+
);
9395
}
9496

9597
return (

src/app/features/dashboard/components/dashboard-section-router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function DashboardSectionRouter({ controller }: DashboardSectionRouterPro
4444
handleDeleteCard,
4545
handleDragEnd,
4646
handleDragOver,
47+
handleDragStart,
4748
handleRemoveEntity,
4849
handleUpdateCard,
4950
hiddenEntityIds,
@@ -228,6 +229,7 @@ export function DashboardSectionRouter({ controller }: DashboardSectionRouterPro
228229
<DndContext
229230
sensors={sensors}
230231
collisionDetection={closestCenter}
232+
onDragStart={handleDragStart}
231233
onDragOver={handleDragOver}
232234
onDragEnd={handleDragEnd}
233235
>

src/app/features/dashboard/hooks/use-dashboard-controller.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DragEndEvent, DragOverEvent, useSensors } from '@dnd-kit/core';
1+
import type { DragEndEvent, DragOverEvent, DragStartEvent, useSensors } from '@dnd-kit/core';
22
import { useCallback, useEffect, useState } from 'react';
33
import { toast } from 'sonner';
44
import type { CardSize } from '@/app/components/shared/card-size-selector';
@@ -52,6 +52,7 @@ export type DashboardController = OnboardingController &
5252
handleDeleteCard: (cardId: string) => void;
5353
handleDragEnd: (_event: DragEndEvent) => void;
5454
handleDragOver: (event: DragOverEvent) => void;
55+
handleDragStart: (event: DragStartEvent) => void;
5556
handleRemoveEntity: (entityId: string) => void;
5657
handleUpdateCard: (cardId: string, data: Record<string, unknown>) => void;
5758
hiddenEntityIds: string[];
@@ -118,11 +119,12 @@ export function useDashboardController(): DashboardController {
118119
hiddenEntityIds,
119120
roomOrder,
120121
});
121-
const { handleDragEnd, handleDragOver, sensors } = useDashboardDnd({
122-
cardOrders,
123-
getCardRoom,
124-
moveCard,
125-
});
122+
const { activeCardOrders, handleDragEnd, handleDragOver, handleDragStart, sensors } =
123+
useDashboardDnd({
124+
cardOrders,
125+
getCardRoom,
126+
moveCard,
127+
});
126128

127129
const dialogs = useDashboardDialogs();
128130
const onboarding = useOnboardingController({ allEntityIds, changeRoom });
@@ -175,7 +177,7 @@ export function useDashboardController(): DashboardController {
175177
allEntityIds,
176178
allViewGrouping,
177179
availableDeviceMap,
178-
cardOrders,
180+
cardOrders: activeCardOrders,
179181
cardSizes,
180182
changeRoom,
181183
customCards,
@@ -187,6 +189,7 @@ export function useDashboardController(): DashboardController {
187189
handleDeleteCard,
188190
handleDragEnd,
189191
handleDragOver,
192+
handleDragStart,
190193
handleRemoveEntity,
191194
handleUpdateCard,
192195
hiddenEntityIds,

src/app/features/dashboard/hooks/use-dashboard-dnd.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {
22
type DragEndEvent,
33
type DragOverEvent,
4+
type DragStartEvent,
45
PointerSensor,
56
useSensor,
67
useSensors,
78
} from '@dnd-kit/core';
8-
import { useCallback } from 'react';
9+
import { useCallback, useRef, useState } from 'react';
910

1011
interface UseDashboardDndParams {
1112
cardOrders: Record<string, string[]>;
@@ -22,6 +23,29 @@ export function useDashboardDnd({ cardOrders, getCardRoom, moveCard }: UseDashbo
2223
})
2324
);
2425

26+
// Local drag-phase state — updated on every dragOver without touching the global store.
27+
// Only the final position is committed to the store on dragEnd (one write per drop).
28+
const dragOrdersRef = useRef<Record<string, string[]> | null>(null);
29+
const activeIdRef = useRef<string | null>(null);
30+
const activeRoomRef = useRef<string | null>(null);
31+
const [dragOrders, setDragOrders] = useState<Record<string, string[]> | null>(null);
32+
33+
// During an active drag, render from the local copy; otherwise from the store.
34+
const activeCardOrders = dragOrders ?? cardOrders;
35+
36+
const handleDragStart = useCallback(
37+
(event: DragStartEvent) => {
38+
const activeId = event.active.id as string;
39+
const room = getCardRoom(activeId);
40+
const snapshot = { ...cardOrders };
41+
activeIdRef.current = activeId;
42+
activeRoomRef.current = room;
43+
dragOrdersRef.current = snapshot;
44+
setDragOrders(snapshot);
45+
},
46+
[cardOrders, getCardRoom]
47+
);
48+
2549
const handleDragOver = useCallback(
2650
(event: DragOverEvent) => {
2751
const { active, over } = event;
@@ -33,26 +57,56 @@ export function useDashboardDnd({ cardOrders, getCardRoom, moveCard }: UseDashbo
3357
const activeId = active.id as string;
3458
const overId = over.id as string;
3559
const room = getCardRoom(activeId);
60+
3661
if (!room || room !== getCardRoom(overId)) {
3762
return;
3863
}
3964

40-
const roomCardIds = cardOrders[room] || [];
65+
const current = dragOrdersRef.current ?? cardOrders;
66+
const roomCardIds = [...(current[room] || [])];
4167
const oldIndex = roomCardIds.indexOf(activeId);
4268
const newIndex = roomCardIds.indexOf(overId);
4369

44-
if (oldIndex !== -1 && newIndex !== -1) {
45-
moveCard(room, oldIndex, newIndex);
70+
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
71+
return;
4672
}
73+
74+
roomCardIds.splice(oldIndex, 1);
75+
roomCardIds.splice(newIndex, 0, activeId);
76+
const updated = { ...current, [room]: roomCardIds };
77+
dragOrdersRef.current = updated;
78+
setDragOrders(updated);
4779
},
48-
[cardOrders, getCardRoom, moveCard]
80+
[cardOrders, getCardRoom]
4981
);
5082

51-
const handleDragEnd = useCallback((_event: DragEndEvent) => {}, []);
83+
const handleDragEnd = useCallback(
84+
(_event: DragEndEvent) => {
85+
const activeId = activeIdRef.current;
86+
const room = activeRoomRef.current;
87+
88+
if (activeId && room && dragOrdersRef.current) {
89+
const originalIndex = cardOrders[room]?.indexOf(activeId) ?? -1;
90+
const finalIndex = dragOrdersRef.current[room]?.indexOf(activeId) ?? -1;
91+
92+
if (originalIndex !== -1 && finalIndex !== -1 && originalIndex !== finalIndex) {
93+
moveCard(room, originalIndex, finalIndex);
94+
}
95+
}
96+
97+
activeIdRef.current = null;
98+
activeRoomRef.current = null;
99+
dragOrdersRef.current = null;
100+
setDragOrders(null);
101+
},
102+
[cardOrders, moveCard]
103+
);
52104

53105
return {
106+
activeCardOrders,
54107
handleDragEnd,
55108
handleDragOver,
109+
handleDragStart,
56110
sensors,
57111
};
58112
}

0 commit comments

Comments
 (0)