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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user