From d9f2af32d68ba67f80704c6f217498ee965584af Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 22 Sep 2026 22:45:24 +0200 Subject: [PATCH] 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
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 --- .../dashboard/dashboard-grid.test.tsx | 67 ++++++++++++++- .../components/dashboard/dashboard-grid.tsx | 82 ++++++++++++++++--- 2 files changed, 137 insertions(+), 12 deletions(-) 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 ( -
+