fix(quick-260922-vdk): Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus

- Messung an Ref-Rueckruf measureRef gehaengt statt an Effekt mit leerer
  Abhaengigkeitsliste: der Effekt sah den gemessenen Knoten nie, wenn er
  waehrend eines leeren Dashboards einhaengt, weil der fruehe Ruecksprung
  in den Leerzustand das <div> gar nicht rendert
- Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen
- Fenster-Horcher als Netz, zusaetzlich zum ResizeObserver
- applyWidth verwirft 0 und nicht endliche Werte (T-VDK-01/T-VDK-03)
- Beobachter trennt sich im null-Zweig des Ref-Rueckrufs (T-VDK-02)
- Zwei neue Regressionstests (Test 10/11), zuerst rot nachgewiesen
- FREE_PLACEMENT_COMPACTOR, Konstanten, Leerzustand unveraendert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:45:24 +02:00
parent 3e8c0f4ef5
commit d9f2af32d6
2 changed files with 137 additions and 12 deletions
@@ -1,6 +1,7 @@
import { Children, isValidElement } from 'react'; 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -84,6 +85,10 @@ vi.mock('@/lib/stores/dashboard-store', () => ({
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); 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', () => { describe('DashboardGrid', () => {
@@ -387,4 +392,64 @@ describe('DashboardGrid', () => {
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).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(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
expect(screen.getByText('No active widgets')).toBeInTheDocument();
expect(captured.props).toBeNull();
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
// misst synchron vor dem Zeichnen. Vor der Aenderung bleibt width bei 1200.
rerender(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
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(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
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);
});
}); });
@@ -1,6 +1,6 @@
'use client'; '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 { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
@@ -125,19 +125,79 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
// Measure container width (v2 requires explicit width — Pitfall 1) // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
useEffect(() => { // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
if (!containerRef.current) return; //
const observer = new ResizeObserver((entries) => { // Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
setWidth(entries[0].contentRect.width); // <div> gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
}); // alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
observer.observe(containerRef.current); // Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
return () => observer.disconnect(); // wenn spaeter die erste Kachel erscheint und das <div> 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<HTMLDivElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(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 — // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt. // stabil bleibt.
@@ -162,7 +222,7 @@ export function DashboardGrid({
} }
return ( return (
<div ref={containerRef}> <div ref={measureRef}>
<Responsive <Responsive
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}