diff --git a/apps/web/src/components/dashboard/dashboard-grid.test.tsx b/apps/web/src/components/dashboard/dashboard-grid.test.tsx
index d16cebb..2220bff 100644
--- a/apps/web/src/components/dashboard/dashboard-grid.test.tsx
+++ b/apps/web/src/components/dashboard/dashboard-grid.test.tsx
@@ -1,6 +1,7 @@
import { Children, isValidElement } from 'react';
-import { cleanup, render, screen } from '@testing-library/react';
+import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
+import { stubResizeObserver } from '@/test/fake-resize-observer';
// Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -84,6 +85,10 @@ vi.mock('@/lib/stores/dashboard-store', () => ({
afterEach(() => {
cleanup();
vi.restoreAllMocks();
+ // quick-260922-vdk: stubResizeObserver ersetzt den globalen ResizeObserver
+ // per vi.stubGlobal — vi.restoreAllMocks() setzt das nicht zurueck, ohne
+ // diese Zeile bliebe der gestubbte Beobachter fuer alle folgenden Dateien stehen.
+ vi.unstubAllGlobals();
});
describe('DashboardGrid', () => {
@@ -387,4 +392,64 @@ describe('DashboardGrid', () => {
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull();
});
+
+ it('quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben', async () => {
+ stubResizeObserver({ width: 1000, height: 800 });
+ captured.props = null;
+ const { DashboardGrid } = await import('./dashboard-grid');
+
+ const { rerender } = render(
+ ,
+ );
+
+ // Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
+ expect(screen.getByText('No active widgets')).toBeInTheDocument();
+ expect(captured.props).toBeNull();
+
+ // Erste Kachel erscheint -> der Raster-
wird eingehaengt -> measureRef
+ // misst synchron vor dem Zeichnen. Vor der Aenderung bleibt width bei 1200.
+ rerender(
+
,
+ );
+
+ expect(captured.props?.width).toBe(1000);
+ });
+
+ it('quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu', async () => {
+ stubResizeObserver({ width: 1000, height: 800 });
+ captured.props = null;
+ const { DashboardGrid } = await import('./dashboard-grid');
+
+ render(
+
,
+ );
+
+ expect(captured.props?.width).toBe(1000);
+
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
+ act(() => {
+ window.dispatchEvent(new Event('resize'));
+ });
+
+ // Vor der Aenderung gibt es keinen Fenster-Horcher: width bliebe bei 1000.
+ expect(captured.props?.width).toBe(1600);
+ });
});
diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx
index 4571b64..854921e 100644
--- a/apps/web/src/components/dashboard/dashboard-grid.tsx
+++ b/apps/web/src/components/dashboard/dashboard-grid.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
@@ -125,19 +125,79 @@ export function DashboardGrid({
onRemoveWidget,
}: DashboardGridProps) {
const t = useTranslations('widgets');
- const containerRef = useRef
(null);
const [width, setWidth] = useState(1200);
- // Measure container width (v2 requires explicit width — Pitfall 1)
- useEffect(() => {
- if (!containerRef.current) return;
- const observer = new ResizeObserver((entries) => {
- setWidth(entries[0].contentRect.width);
- });
- observer.observe(containerRef.current);
- return () => observer.disconnect();
+ // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
+ // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
+ //
+ // Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
+ // gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
+ // alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
+ // Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
+ // wenn spaeter die erste Kachel erscheint und das
tatsaechlich
+ // entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert
+ // 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als
+ // (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt
+ // 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts.
+ //
+ // Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen
+ // hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort
+ // ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein
+ // zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200
+ // lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau
+ // diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute
+ // schon in Produktion und er ist nachweislich richtig (459 px in 1176 px
+ // gemessen) — ein anderer Startwert wuerde eine bisher unerprobte
+ // Breakpoint-Folge einfuehren, ohne etwas zu verbessern.
+ //
+ // Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der
+ // ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0
+ // und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig
+ // zusammengefallene Flaeche das Raster nicht auf Null setzt und keine
+ // Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst.
+ const nodeRef = useRef
(null);
+ const observerRef = useRef(null);
+
+ const applyWidth = useCallback((next: number) => {
+ if (Number.isFinite(next) && next > 0) {
+ setWidth(next);
+ }
}, []);
+ const measureRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ // Ein eventuell laufender Beobachter zuerst trennen — auch der
+ // null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der
+ // Aufraeumpfad (T-VDK-02).
+ observerRef.current?.disconnect();
+ observerRef.current = null;
+ nodeRef.current = node;
+ if (!node) return;
+
+ // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
+ applyWidth(node.getBoundingClientRect().width);
+
+ const observer = new ResizeObserver((entries) => {
+ applyWidth(entries[0].contentRect.width);
+ });
+ observer.observe(node);
+ observerRef.current = observer;
+ // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
+ // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
+ },
+ [applyWidth],
+ );
+
+ useEffect(() => {
+ const onResize = () => {
+ if (nodeRef.current) {
+ applyWidth(nodeRef.current.getBoundingClientRect().width);
+ }
+ };
+ window.addEventListener('resize', onResize);
+ return () => window.removeEventListener('resize', onResize);
+ }, [applyWidth]);
+
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt.
@@ -162,7 +222,7 @@ export function DashboardGrid({
}
return (
-