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,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<HTMLDivElement>(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
// <div> 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 <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 —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt.
@@ -162,7 +222,7 @@ export function DashboardGrid({
}
return (
<div ref={containerRef}>
<div ref={measureRef}>
<Responsive
width={width}
breakpoints={BREAKPOINTS}