fix(quick-260921-ldf): Fehler-melden-Dialog stimmt ab dem ersten Commit, nicht erst einen spaeter

Gemessene Ursache des Wackeltests aus CI-Lauf 395, und es ist ein
Produktfehler, kein Testfehler. Ein MutationObserver ueber jeden
DOM-Commit beim Oeffnen protokollierte:

  COMMIT dialog=true img=ja box=AUS   <- falsch, aber festgeschrieben
  COMMIT dialog=true img=ja box=AN

Der erste Zustand entstand bei JEDEM Oeffnen, nicht nur unter Last, und
hielt ohne act() zwei volle Makrotask-Runden - der Browser hat in dieser
Zeit mindestens zwei Gelegenheiten, ihn zu zeichnen. Ein Nutzer sieht
also sein Vorschaubild kurz mit ausgeschaltetem Haekchen. Der Test fiel
nur dann durch, wenn er zufaellig den ersten statt den zweiten Commit
sah; die Last im vollen Lauf war der Ausloeser, nicht die Ursache.

Zwei Bedingungen mussten zusammentreffen. Erstens war der Dialog
dauerhaft eingehaengt und gab bei geschlossenem Zustand nur null zurueck
- useState(screenshot !== null) lief damit ein einziges Mal, beim
allerersten Mount des Knopfs, als noch gar kein Bild da war. Das
Haekchen startete also immer aus. Zweitens zog ein useEffect den
Zustand nach, und passive Effekte laufen erst NACH dem Commit.

Beides ist jetzt weg. Der Dialog wird nur noch eingehaengt, solange er
offen ist, also ist jedes Oeffnen ein frischer Mount mit frischem
Zustand. Und das Haekchen wird beim Rendern aus screenshot abgeleitet
statt per Effekt nachgezogen; attachChoice haelt allein die bewusste
Abwahl des Nutzers. Der Effekt, der Status, Text und Haekchen beim
Oeffnen zuruecksetzte, entfaellt ersatzlos.

Damit verschwindet dieselbe Klasse an einer zweiten Stelle: beim
erneuten Oeffnen nach einem Versand stand bisher zwei Runden lang der
alte Danke-Bildschirm im DOM, bevor das frische Formular erschien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 15:38:38 +02:00
parent c0ab5b5584
commit de7fdb7377
2 changed files with 22 additions and 20 deletions
@@ -66,7 +66,11 @@ export function BugReportButton() {
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" /> <path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg> </svg>
</button> </button>
<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} /> {/* Nur eingehaengt, solange offen (quick-260921-ldf): so startet jedes
Oeffnen mit frischem Zustand — Haekchen, Text und Status stimmen
schon im ersten Commit, statt einen Commit spaeter nachgezogen zu
werden. */}
{open && <BugReportDialog screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />}
</> </>
); );
} }
@@ -13,9 +13,13 @@ import { formatErrorsForReport } from '@/lib/error-buffer';
* `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`, * `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`,
* Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL — * Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL —
* die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint. * die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint.
*
* Der Dialog wird nur eingehaengt, solange er offen ist (quick-260921-ldf):
* jedes Oeffnen ist ein frischer Mount, damit gilt der Anfangszustand schon
* im ERSTEN Commit. Vorher zog ein Effekt den Zustand erst einen Commit
* spaeter nach — dazwischen stand sichtbar "Bild da, Haekchen aus" im DOM.
*/ */
interface BugReportDialogProps { interface BugReportDialogProps {
open: boolean;
screenshot: string | null; screenshot: string | null;
isAdmin: boolean; isAdmin: boolean;
onClose: () => void; onClose: () => void;
@@ -23,35 +27,29 @@ interface BugReportDialogProps {
type Status = 'ready' | 'sending' | 'sent' | 'failed'; type Status = 'ready' | 'sending' | 'sent' | 'failed';
export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugReportDialogProps) { export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialogProps) {
const t = useTranslations('bugReport'); const t = useTranslations('bugReport');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<Status>('ready'); const [status, setStatus] = useState<Status>('ready');
const [failedStatus, setFailedStatus] = useState(0); const [failedStatus, setFailedStatus] = useState(0);
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [attach, setAttach] = useState(screenshot !== null); // Das Haekchen wird beim Rendern aus `screenshot` abgeleitet, nicht per
// Effekt nachgezogen: gibt es ein Bild, ist es an. `attachChoice` haelt
// allein die bewusste Abwahl des Nutzers.
const [attachChoice, setAttachChoice] = useState<boolean | null>(null);
const attach = screenshot !== null && (attachChoice ?? true);
// Bei jedem Oeffnen frisch beginnen.
useEffect(() => { useEffect(() => {
if (open) {
setStatus('ready');
setFailedStatus(0);
setDescription('');
setAttach(screenshot !== null);
textareaRef.current?.focus(); textareaRef.current?.focus();
} }, []);
}, [open, screenshot]);
useEffect(() => { useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && status !== 'sending') onClose(); if (e.key === 'Escape' && status !== 'sending') onClose();
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [open, status, onClose]); }, [status, onClose]);
if (!open) return null;
const handleSend = async () => { const handleSend = async () => {
setStatus('sending'); setStatus('sending');
@@ -144,7 +142,7 @@ export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugRepor
className="h-4 w-4" className="h-4 w-4"
checked={attach} checked={attach}
disabled={screenshot === null || busy} disabled={screenshot === null || busy}
onChange={(e) => setAttach(e.target.checked)} onChange={(e) => setAttachChoice(e.target.checked)}
/> />
<label htmlFor="bug-report-attach" className="text-sm text-foreground"> <label htmlFor="bug-report-attach" className="text-sm text-foreground">
{t('attachScreenshot')} {t('attachScreenshot')}