Stop the print preview measuring itself into a freeze

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:44:20 +02:00
parent 578ce696f0
commit 00df973824
2 changed files with 60 additions and 8 deletions

View File

@@ -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<string, number> }>({ 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 (
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">