diff --git a/components/orgchart/PrintChart.tsx b/components/orgchart/PrintChart.tsx index 4d5f203..d1331b9 100644 --- a/components/orgchart/PrintChart.tsx +++ b/components/orgchart/PrintChart.tsx @@ -2,7 +2,7 @@ import { ChevronRight, Printer } from "lucide-react"; import Link from "next/link"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/Button"; import { fmtDate } from "@/lib/format"; import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print"; @@ -146,11 +146,17 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record }>({ signatur, werte: {} }); const meldeMassstab = useCallback( (key: string, faktor: number) => { - setGemessen((prev) => - prev.signatur === signatur + setGemessen((prev) => { + // Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht + // ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React + // als Änderung und löst ein Rendern aus — und da das Rendern die + // Messung anstösst, die wieder hier landet, dreht sich das ohne Ende. + // Genau daran ist die Vorschau eingefroren. + if (prev.signatur === signatur && prev.werte[key] === faktor) return prev; + return prev.signatur === signatur ? { signatur, werte: { ...prev.werte, [key]: faktor } } - : { signatur, werte: { [key]: faktor } } - ); + : { signatur, werte: { [key]: faktor } }; + }); }, [signatur] ); @@ -433,6 +439,16 @@ function Skaliert({ const [faktor, setFaktor] = useState(1); const [versatz, setVersatz] = useState(0); + // Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem + // Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt + // nach jedem Rendern erneut — samt Messung, die das nächste Rendern + // auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne + // dass der Effekt daran hängt. + const melden = useRef(onFaktor); + useEffect(() => { + melden.current = onFaktor; + }); + useLayoutEffect(() => { const aussen = rahmen.current; const innen = inhalt.current; @@ -450,7 +466,7 @@ function Skaliert({ // Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst // klebt ein schmales Diagramm am linken Blattrand. setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2)); - onFaktor(f); + melden.current(f); } messen(); @@ -460,7 +476,7 @@ function Skaliert({ const beobachter = new ResizeObserver(messen); beobachter.observe(aussen); return () => beobachter.disconnect(); - }, [kennung, onFaktor]); + }, [kennung]); return (
diff --git a/tests/components/PrintChart.test.tsx b/tests/components/PrintChart.test.tsx index f8a7410..1c02dfa 100644 --- a/tests/components/PrintChart.test.tsx +++ b/tests/components/PrintChart.test.tsx @@ -1,6 +1,6 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { PrintChart } from "@/components/orgchart/PrintChart"; import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types"; import { buildPrintModel } from "@/lib/orgchart-print"; @@ -67,6 +67,42 @@ async function zurVorschau(user: ReturnType) { await user.click(screen.getByRole("button", { name: /Vorschau/ })); } +// Die Vorschau misst sich selbst, um den Massstab zu bestimmen. Das ist eine +// Rückkopplung: messen → Zustand ändern → neu rendern → messen. Bricht sie +// nicht ab, friert der Browser-Tab ein, sobald jemand die Vorschau öffnet. +// +// In jsdom hat jedes Element die Grösse 0, deshalb steigt die Messung dort +// sonst sofort aus und die Schleife entsteht gar nicht erst — genau deshalb +// ist der Fehler durch alle bisherigen Tests gekommen. Hier bekommen die +// Elemente Masse, damit die Rückkopplung wirklich läuft. +describe("PrintChart — Messung", () => { + const masse = { clientWidth: 900, clientHeight: 600 }; + const gesichert: Record = {}; + + beforeAll(() => { + for (const [name, wert] of Object.entries(masse)) { + gesichert[name] = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name); + Object.defineProperty(HTMLElement.prototype, name, { configurable: true, value: wert }); + } + }); + + afterAll(() => { + for (const name of Object.keys(masse)) { + if (gesichert[name]) Object.defineProperty(HTMLElement.prototype, name, gesichert[name]!); + else delete (HTMLElement.prototype as unknown as Record)[name]; + } + }); + + it("kommt zur Ruhe, statt sich selbst immer weiter neu zu vermessen", async () => { + const user = userEvent.setup(); + renderChart(); + // Ein Durchlauf, der nicht abbricht, endet in React mit „Maximum update + // depth exceeded" — der Klick wirft dann. + await user.click(screen.getByRole("button", { name: /Vorschau/ })); + expect(blaetter()).toHaveLength(3); + }); +}); + describe("PrintChart", () => { it("fragt zuerst, was gedruckt wird, und zeigt vorher kein Blatt", () => { renderChart();