From 00df973824e9fa8059141f81106f63533489f1be Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 13 Aug 2026 17:44:20 +0200 Subject: [PATCH] Stop the print preview measuring itself into a freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the org chart PDF preview locked up the browser tab. The measurement that fits each sheet to the page fed itself: the effect listed onFaktor in its dependencies, and onFaktor was an arrow function created fresh on every render, so the effect re-ran after every render. It measured, reported the scale, and the report called setState with a newly built object every time — new object, so React saw a change, re-rendered, and the effect ran again. Measure, render, measure, until React gave up with "Maximum update depth exceeded". Two changes, and the mutation test says either one closes the loop on its own: the callback now lives in a ref so the effect depends only on the sheet identity, and the reducer returns the previous state unchanged when the scale has not moved. Both are worth keeping — the ref stops the effect from re-running, the guard stops pointless renders. This shipped broken, and the reason it shipped is in the test file now. Every element in jsdom is zero pixels, so the measurement bailed out on its first line and the feedback never started; nine tests covering the selection, the page count and the hierarchy all passed against a component that froze on contact with a real browser. The new test gives the elements a size, and fails with the exact error a user hits. Co-Authored-By: Claude Opus 5 --- components/orgchart/PrintChart.tsx | 30 +++++++++++++++++----- tests/components/PrintChart.test.tsx | 38 +++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) 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();