--- status: resolved trigger: "CI 395 rot: apps/web/src/components/bug-report/bug-report-button.test.tsx Test 1 -- Vorschaubild da, Haekchen 'Bildschirmfoto anhaengen' aus. 1 Fehlschlag in ~17 vollen Laeufen, isoliert nie." created: 2026-09-21T00:00:00Z updated: 2026-09-21T00:00:00Z symptoms_prefilled: true goal: find_and_fix --- ## Current Focus reasoning_checkpoint: hypothesis: "attach ist abgeleiteter Zustand, der per passivem useEffect nachgezogen wird. Da BugReportDialog dauerhaft eingehaengt ist, laeuft useState(screenshot !== null) nur beim ersten Mount (screenshot noch null) -> attach startet immer false. Deshalb committet React BEI JEDEM Oeffnen zuerst einen DOM-Zustand 'Dialog offen + Bild da + Haekchen AUS' und korrigiert ihn erst im naechsten Commit." confirming_evidence: - "MutationObserver-Protokoll (H2): COMMIT dialog=true img=ja box=AUS, danach COMMIT dialog=true img=ja box=AN -- der falsche Zustand ist ein echter, committeter DOM-Zustand, kein Testartefakt." - "H3 (roher Klick ohne act): der falsche Zustand haelt ZWEI volle Makrotask-Runden. Zwei Makrotask-Grenzen = zwei Gelegenheiten des Browsers zu zeichnen." - "H1 (kuenstlicher Makrotask im toPng-Mock): Fehlschlag 4 von 4, exakt dieselbe Meldung wie in CI 395 -- die Wackelbedingung ist reine Beobachtungszeit." - "H3b: dasselbe Muster beim erneuten Oeffnen -- der alte Danke-Bildschirm steht zwei Runden lang im DOM, bevor das frische Formular erscheint." falsification_test: "Waere es ein reines Testartefakt, duerfte im MutationObserver-Protokoll kein Commit mit img=ja/box=AUS auftauchen. Er taucht auf, ausnahmslos, bei jedem Oeffnen." fix_rationale: "Ursache ist die Konstruktion: Zustand wird per Effekt nachgezogen statt beim Rendern abgeleitet, und der Dialog bleibt ueber das Schliessen hinaus eingehaengt. Beides beseitigen: (1) attach waehrend des Renderns aus screenshot ableiten, (2) den Dialog nur einhaengen, solange er offen ist -> jeder Oeffnungsvorgang startet mit frischem Zustand, schon im ersten Commit." blind_spots: "Ob der Browser den Zwischen-Frame tatsaechlich zeichnet, ist hier nicht im echten Browser gemessen -- belegt ist, dass der falsche Zustand zwei Makrotask-Grenzen ueberdauert, also mindestens zwei Zeichengelegenheiten offenstehen." candidate_causes: - "code: abgeleiteter Zustand per passivem Effekt statt beim Rendern (bestaetigt)" - "code: Dialog dauerhaft eingehaengt -> useState-Startwert veraltet (bestaetigt, zweite Teilursache)" - "environment: Ereignisschleifen-Last im vollen Vitest-Lauf (nur Ausloeser der Beobachtung, nicht Ursache)" - "data: Datenform des Bildes -- ausgeschlossen, img src ist im Fehlerfall korrekt" and_gate: "ja -- zwei Bedingungen zusammen: (a) attach wird per Effekt nachgezogen UND (b) der Dialog bleibt eingehaengt, sodass der useState-Startwert aus der Zeit vor dem ersten Bild stammt. Ohne (b) waere (a) beim ersten Oeffnen unauffaellig; ohne (a) waere (b) folgenlos." test: Fix anwenden, danach H2/H3-Instrumentierung erneut laufen lassen expecting: Erster Commit mit Dialog traegt bereits Haekchen AN next_action: bug-report-dialog.tsx und bug-report-button.tsx anpassen ## Symptoms expected: Nach Klick auf den Fehler-melden-Knopf oeffnet der Dialog mit Vorschaubild UND gesetztem Haekchen "Bildschirmfoto anhaengen". actual: Vorschaubild ist da (img src == DATA_URL, Checkbox nicht disabled), aber die Checkbox ist nicht checked. errors: | Error: expect(element).toBeChecked() Received element is not checked: bug-report-button.test.tsx:126:72 reproduction: pnpm --filter @tessera/web exec vitest run (voller Lauf), ~1 von 17. Isoliert (nur die Datei) in 6 Laeufen nie. started: CI-Lauf 395 (2026-09-21), Test existiert seit quick-260914-m97 ## Eliminated - hypothesis: "Reines Testartefakt -- die Pruefung misst einen Zustand, den der Nutzer nie sieht" evidence: "MutationObserver-Protokoll zeigt den Zustand als echten DOM-Commit, der zwei Makrotask-Runden ueberdauert. Der Browser hat in dieser Zeit mindestens zwei Zeichengelegenheiten." timestamp: T2 - hypothesis: "Der dynamische Import von html-to-image kostet einen Makrotask und kippt dadurch die Reihenfolge" evidence: "Gemessen: await import('html-to-image') loest ohne Makrotask-Grenze auf (Timer 0, davor gesetzt, feuert NACH dem Import). Der Import ist nicht die Ursache -- die falsche Reihenfolge besteht auch ohne ihn." timestamp: T2 ## Evidence - timestamp: T0 checked: apps/web/src/components/bug-report/bug-report-button.tsx found: "handleClick: setCapturing(true); const shot = await captureScreenshot(); setScreenshot(shot); setOpen(true); setCapturing(false). BugReportDialog wird IMMER gerendert (kein bedingtes Mounten) -- die Instanz bleibt ueber open-Wechsel hinweg bestehen." implication: "useState(screenshot !== null) im Dialog laeuft nur EINMAL, beim ersten Mount des Knopfs, da ist screenshot noch null -> attach startet IMMER false. Das Haekchen wird ausschliesslich durch den useEffect gesetzt." - timestamp: T0 checked: apps/web/src/components/bug-report/bug-report-dialog.tsx found: "const [attach, setAttach] = useState(screenshot !== null); useEffect(() => { if (open) { ... setAttach(screenshot !== null); ... } }, [open, screenshot]);" implication: "attach ist abgeleiteter Zustand, synchronisiert per passivem Effekt. Zwischen dem Commit (Bild im DOM) und dem Lauf des passiven Effekts (Haekchen an) existiert zwangslaeufig ein Zustand 'Bild da, Haekchen aus'." - timestamp: T0 checked: apps/web/src/lib/bug-report-api.ts captureScreenshot found: "await import('html-to-image') -- dynamischer Import VOR dem toPng-Aufruf; Fehler werden geschluckt (catch -> null)." implication: "Zwei await-Stufen vor setScreenshot/setOpen. Unter Last kann die Aufloesung nach dem Ende des act()-Bereichs von user.click() landen." - timestamp: T1 checked: "Kuenstlicher Makrotask im toPng-Mock (zz-repro.test.tsx, Verzoegerung 0/1/5/20 ms)" found: "4 von 4 Fehlschlaegen mit exakt der CI-Meldung 'Received element is not checked'." implication: "Jede Makrotask-Grenze in der Aufnahmekette genuegt, damit die Pruefung den falschen Zwischenzustand sieht. Die Last im vollen Lauf ist nur der Ausloeser." - timestamp: T2 checked: "MutationObserver ueber document.body waehrend des Oeffnens (zz-repro3.test.tsx), toPng rein mikrotask wie im echten Test" found: | COMMIT dialog=false img=nein box=- COMMIT dialog=false img=nein box=- COMMIT dialog=true img=ja box=AUS <- falscher Zustand, committet COMMIT dialog=true img=ja box=AN implication: "Der Zustand 'Bild da, Haekchen aus' ist ein echter, committeter DOM-Zustand -- bei JEDEM Oeffnen, nicht nur unter Last. Der Test faellt nur dann durch, wenn er zufaellig den ersten statt den zweiten Commit sieht." - timestamp: T2 checked: "Roher Klick ohne act(), Sampling pro Makrotask-Runde (zz-repro4.test.tsx)" found: "runde 1: bild=ja haekchen=AUS | runde 2: bild=ja haekchen=AUS | runde 3: bild=ja haekchen=AN" implication: "Der falsche Zustand ueberdauert zwei volle Ereignisschleifen-Runden. Im Browser liegen damit mindestens zwei Zeichengelegenheiten in diesem Zustand -> fuer den Nutzer sichtbar." - timestamp: T2 checked: "Erneutes Oeffnen nach Versand (zz-repro4.test.tsx, H3b)" found: "runde 2 und 3 zeigen den ALTEN Danke-Bildschirm (danke=true), erst runde 4 das frische Formular." implication: "Zweite Auspraegung derselben Ursache: auch status/description werden erst per Effekt zurueckgesetzt. Der Fix muss beide Teilursachen beseitigen." - timestamp: T2 checked: "await import('html-to-image') gegen setTimeout(0) (zz-repro2.test.tsx)" found: "[IMPORT-1] timerFired=false 1.13ms, [IMPORT-2] timerFired=false 0.18ms" implication: "Der dynamische Import ueberschreitet keine Makrotask-Grenze -- er ist nicht die Ursache." ## Resolution root_cause: | Produktfehler, zwei Teilursachen im UND-Verbund (bestaetigt per MutationObserver ueber jeden DOM-Commit): (a) BugReportDialog war dauerhaft eingehaengt und gab bei geschlossenem Zustand nur `null` zurueck. `useState(screenshot !== null)` lief damit genau einmal, beim allerersten Mount des Knopfs -- da war `screenshot` noch `null`, also startete `attach` immer als `false`. (b) Der Anfangszustand wurde per `useEffect` nachgezogen. Passive Effekte laufen NACH dem Commit. React schrieb deshalb bei JEDEM Oeffnen zuerst den Zustand "Dialog offen + Vorschaubild sichtbar + Haekchen AUS" in den DOM und korrigierte ihn erst im naechsten Commit. Der falsche Zustand ueberdauerte gemessen zwei volle Makrotask-Runden -- der Browser hat in dieser Zeit mindestens zwei Gelegenheiten, ihn zu zeichnen. Die Last im vollen Vitest-Lauf war nur der Ausloeser dafuer, dass die Pruefung den ersten statt den zweiten Commit sah; sie war nie die Ursache. fix: | (a) Der Dialog wird nur noch eingehaengt, solange er offen ist (`{open && }`) -- jedes Oeffnen ist ein frischer Mount, der Anfangszustand gilt schon im ersten Commit. Der zuruecksetzende Effekt entfaellt ersatzlos. (b) Das Haekchen wird beim Rendern aus `screenshot` abgeleitet statt per Effekt nachgezogen: `const attach = screenshot !== null && (attachChoice ?? true)`. `attachChoice` haelt allein die bewusste Abwahl des Nutzers. verification: | signal_reproduktion: bestaetigt -- kuenstlicher Makrotask in der Aufnahmekette erzwang den Fehlschlag 4/4 vor dem Fix, 4/4 gruen danach. signal_regressionstest: Test 14/15 sind gegen den Stand vor dem Fix in 5 von 5 Laeufen rot, danach gruen. Deterministisch, kein retry, kein Zeitlimit. signal_umkehrprobe: Quellcode auf 8d604b8 zurueckgesetzt, neue Tests bleiben -- der Fehler kehrt zurueck. Fix und Fehler haengen nachweislich zusammen. signal_zwischenzustand: MutationObserver-Protokoll nach dem Fix zeigt den ersten Commit mit Dialog bereits als "img=ja box=AN". Kein falscher Commit mehr. signal_kein_loeschfix: der Diff ist kein Wegnehmen einer Pruefung -- Test 1 prueft unveraendert dieselbe Zusicherung, zwei Tests kamen hinzu. gates: lint 5/5 ohne Fehlerstufe, 399 Warnungen (unveraendert); type-check 4/4; apps/web 73 Dateien / 531 Tests; apps/api 72 Dateien / 1143 Tests. signal_stabilitaet: 20/20 volle Laeufe von `pnpm --filter @tessera/web exec vitest run` gruen, 0 Fehlschlaege, je 531 Tests. Fuer sich genommen schwach (bei 1:17 waeren 20 gruene Laeufe auch ohne Fix zu ~30 % zu erwarten) -- der tragende Beleg ist der deterministische: den falschen Zustand gibt es nicht mehr. guardrail_verdict: accepted files_changed: - apps/web/src/components/bug-report/bug-report-dialog.tsx - apps/web/src/components/bug-report/bug-report-button.tsx - apps/web/src/components/bug-report/bug-report-button.test.tsx