fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s

Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.

Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:

    const cacheKey = id || src;
    // Script has already loaded
    if (cacheKey && LoadCache.has(cacheKey)) {
      return;
    }

Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:

  - die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
    zwei Widgets — eines davon war immer tot;
  - jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
    statt ueber F5.

Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".

Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.

Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:

  - "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
    Containern sucht;
  - turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
  - ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
    laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
    Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
    abschicken.

Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.

Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.

tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 05:46:14 +02:00
1 parent e5cae9b49d
commit cbc81caa1a
5 files changed
+290 -18

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
/**
* Regressionstests fuer das Turnstile-Widget.
*
* Der behobene Fehler war unauffaellig und folgenschwer zugleich: das Widget
* erschien nicht, es gab also kein Token, und ohne Token weist der Server das
* Formular ab — Anmeldung, Registrierung und Checkout waren blockiert, ohne
* dass irgendwo eine Fehlermeldung stand. Ausgeloest wurde er dadurch, dass
* next/script sein onLoad fuer eine bereits geladene Quelle nicht erneut
* ausfuehrt: das ZWEITE Widget einer Seite und jedes Widget nach einer
* Navigation im Seitenverlauf warteten auf ein Ereignis, das nie kam.
*
* Diese Tests halten genau das fest: ein Widget muss sich auch dann aufbauen,
* wenn das Skript laengst geladen ist.
*/
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TurnstileWidget } from "./turnstile-widget";
import { I18nProvider } from "@/i18n/i18n-provider";
const SITE_KEY = "1x00000000000000000000AA";
// Mit Signatur, damit mock.calls[0][1] (die Optionen) typisiert ist.
const turnstileRender =
vi.fn<(container: HTMLElement, options: Record<string, unknown>) => string>(() => "widget-1");
const turnstileRemove = vi.fn();
function mountWidget(onVerify: (token: string) => void = vi.fn()) {
return render(
<I18nProvider locale="de">
<TurnstileWidget siteKey={SITE_KEY} onVerify={onVerify} />
</I18nProvider>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete window.turnstile;
document.querySelectorAll("script").forEach((script) => script.remove());
});
describe("TurnstileWidget", () => {
test("baut sich auch dann auf, wenn das Skript schon geladen ist", async () => {
// Genau der Fall, der vorher stillschweigend nichts tat: window.turnstile
// existiert bereits (weil die Newsletter-Anmeldung im Footer das Skript
// auf praktisch jeder Seite geladen hat), ein onLoad kommt also nie mehr.
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
mountWidget();
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
expect(turnstileRender.mock.calls[0][1]).toMatchObject({ sitekey: SITE_KEY });
});
test("baut zwei Widgets auf derselben Seite unabhaengig voneinander auf", async () => {
// Die Kontoseite zeigt Anmeldung und Registrierung untereinander, jede mit
// eigenem Widget. Vorher bekam nur eine von beiden eines.
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
render(
<I18nProvider locale="de">
<TurnstileWidget siteKey={SITE_KEY} onVerify={vi.fn()} />
<TurnstileWidget siteKey={SITE_KEY} onVerify={vi.fn()} />
</I18nProvider>
);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledTimes(2));
});
test("reicht das geloeste Token an das Formular weiter", async () => {
const onVerify = vi.fn();
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
mountWidget(onVerify);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
const options = turnstileRender.mock.calls[0][1] as Record<string, (token: string) => void>;
options.callback("0.abc123");
expect(onVerify).toHaveBeenCalledWith("0.abc123");
// Ein abgelaufenes Token muss das Formular ebenso erfahren, sonst schickt
// es ein laengst ungueltiges mit.
options["expired-callback"]("");
expect(onVerify).toHaveBeenLastCalledWith("");
});
test("raeumt das Widget beim Verlassen der Seite wieder ab", async () => {
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
const { unmount } = mountWidget();
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
unmount();
expect(turnstileRemove).toHaveBeenCalledWith("widget-1");
});
test("zeigt einen Hinweis mit Wiederholung, wenn das Skript nicht laedt", async () => {
// jsdom laedt keine externen Skripte — der Fehlerfall wird ausgeloest,
// indem auf dem eingehaengten Element ein "error" ausgeloest wird.
const appendChild = vi
.spyOn(document.head, "appendChild")
.mockImplementation((<T extends Node>(node: T): T => {
queueMicrotask(() => node.dispatchEvent(new Event("error")));
return node;
}) as typeof document.head.appendChild);
mountWidget();
// Der Hinweis ist der eigentliche Fortschritt: vorher blieb an dieser
// Stelle schlicht eine leere Flaeche stehen.
const retry = await screen.findByRole("button", { name: /erneut versuchen/i });
expect(screen.getByText(/sicherheitsprüfung konnte nicht geladen werden/i)).toBeInTheDocument();
// Wiederholung mit inzwischen verfuegbarem Skript: das Widget kommt.
appendChild.mockRestore();
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
await userEvent.click(retry);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
});
});
+151 -18
View File
@@ -1,16 +1,97 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Script from "next/script";
import { useI18n } from "@/i18n/i18n-provider";
interface TurnstileApi {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
remove: (widgetId: string) => void;
}
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
};
turnstile?: TurnstileApi;
}
}
// "render=explicit" schaltet die automatische Suche nach .cf-turnstile-
// Elementen ab: dieses Widget wird ausschliesslich von Hand gerendert, damit
// wir die Widget-ID kennen und es beim Verlassen wieder entfernen koennen.
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
/**
* Laedt das Turnstile-Skript je Seitenbesuch genau einmal; alle Widgets
* teilen sich dieses eine Versprechen.
*
* Zuvor uebernahm das <Script> von next/script diese Aufgabe, und genau daran
* scheiterte es: next/script fuehrt fuer eine bereits geladene Quelle KEIN
* onLoad mehr aus. Der Schluessel ist dabei die URL (id || src), also fuer
* alle Widgets derselbe — nachzulesen in next/dist/client/script.js:
*
* const cacheKey = id || src;
* // Script has already loaded
* if (cacheKey && LoadCache.has(cacheKey)) {
* return;
* }
*
* Damit bekam nur das ERSTE Widget nach einem echten Seitenaufbau sein
* onLoad. Jedes weitere wartete auf ein Ereignis, das nie kam, und blieb
* unsichtbar: das zweite Widget auf der Kontoseite (Anmeldung und
* Registrierung stehen dort untereinander) ebenso wie jedes Formular, das
* ueber einen Link im Seitenverlauf erreicht wurde statt ueber F5. Weil die
* Newsletter-Anmeldung im Footer steht, ist das Skript praktisch immer schon
* geladen — fast jeder Weg durch die Seite fuehrte also in den kaputten
* Zustand, und nur ein Neuladen half.
*
* Deshalb haengt hier nichts mehr an einem Ereignis, das man verpassen kann:
* es zaehlt allein, ob window.turnstile vorhanden ist.
*/
let loadPromise: Promise<TurnstileApi> | null = null;
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile);
if (loadPromise) return loadPromise;
const pending = new Promise<TurnstileApi>((resolve, reject) => {
// Ein vorhandenes Skript-Element wiederverwenden, statt ein zweites
// einzuhaengen: nach einem fehlgeschlagenen Versuch (loadPromise wird
// unten zurueckgesetzt) steht es unter Umstaenden noch im Dokument.
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`);
const script = existing ?? document.createElement("script");
script.addEventListener(
"load",
() => {
if (window.turnstile) resolve(window.turnstile);
else reject(new Error("Turnstile geladen, aber window.turnstile fehlt."));
},
{ once: true }
);
script.addEventListener(
"error",
() => reject(new Error("Turnstile-Skript konnte nicht geladen werden.")),
{ once: true }
);
if (!existing) {
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
});
// Nach einem Fehlschlag darf der naechste Versuch nicht auf demselben
// abgelehnten Versprechen haengen bleiben — sonst waere der "Erneut
// versuchen"-Knopf wirkungslos.
loadPromise = pending;
pending.catch(() => {
loadPromise = null;
});
return pending;
}
interface TurnstileWidgetProps {
siteKey: string;
onVerify: (token: string) => void;
@@ -21,30 +102,82 @@ interface TurnstileWidgetProps {
* NEXT_PUBLIC_TURNSTILE_SITE_KEY konfiguriert ist (siehe README) — ohne
* Konfiguration rendern die umgebenden Formulare gar nicht erst diese
* Komponente und bleiben unveraendert nutzbar.
*
* Achtung beim Anpassen: ohne sichtbares Widget gibt es kein Token, und ohne
* Token weist der Server das Formular ab. Ein stiller Ausfall hier sperrt
* Kundschaft aus Anmeldung, Registrierung und Checkout aus, ohne dass
* irgendwo etwas davon zu sehen waere — daher der sichtbare Hinweis samt
* Wiederholung unten.
*/
export function TurnstileWidget({ siteKey, onVerify }: TurnstileWidgetProps) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
const widgetIdRef = useRef<string | null>(null);
const onVerifyRef = useRef(onVerify);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
// Den Callback im Ref halten, statt ihn in die Abhaengigkeiten zu nehmen:
// eine bei jedem Render neu erzeugte Funktion wuerde das Widget sonst
// staendig abbauen und neu aufbauen — und mit ihm ein bereits geloestes
// CAPTCHA.
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
useEffect(() => {
if (!scriptLoaded || !containerRef.current || !window.turnstile) return;
window.turnstile.render(containerRef.current, {
let cancelled = false;
// setFailed gehoert in die Rueckrufe, nicht in den Rumpf des Effekts: ein
// synchrones setState loest eine sofortige zweite Renderrunde aus (siehe
// react-hooks/set-state-in-effect). Beim Wiederholen wird der Hinweis
// deshalb erst zurueckgenommen, wenn das Laden tatsaechlich geklappt hat.
loadTurnstile()
.then((turnstile) => {
if (cancelled || !containerRef.current) return;
setFailed(false);
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: onVerify,
"expired-callback": () => onVerify(""),
"error-callback": () => onVerify(""),
callback: (token: string) => onVerifyRef.current(token),
// Ein abgelaufenes oder fehlgeschlagenes Token muss das Formular
// erfahren, sonst schickt es ein laengst ungueltiges mit.
"expired-callback": () => onVerifyRef.current(""),
"error-callback": () => onVerifyRef.current(""),
});
}, [scriptLoaded, siteKey, onVerify]);
})
.catch((error: unknown) => {
if (cancelled) return;
console.error("[turnstile] Widget konnte nicht geladen werden:", error);
setFailed(true);
});
return () => {
cancelled = true;
const widgetId = widgetIdRef.current;
widgetIdRef.current = null;
if (!widgetId) return;
try {
window.turnstile?.remove(widgetId);
} catch (error: unknown) {
// Nicht weiterwerfen: ein Fehler beim Aufraeumen darf den
// Seitenwechsel nicht abbrechen.
console.warn("[turnstile] Widget konnte nicht entfernt werden:", error);
}
};
}, [siteKey, attempt]);
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onLoad={() => setScriptLoaded(true)}
/>
<div>
<div ref={containerRef} />
</>
{failed ? (
<p className="text-sm text-red-700">
{t.common.turnstile.loadError}{" "}
<button type="button" onClick={() => setAttempt((value) => value + 1)} className="underline">
{t.common.turnstile.retry}
</button>
</p>
) : null}
</div>
);
}
+4
View File
@@ -9,6 +9,10 @@ export const de: Dictionary = {
KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis",
},
turnstile: {
loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.",
retry: "Erneut versuchen",
},
},
nav: {
shop: "Shop",
+4
View File
@@ -9,6 +9,10 @@ export const en: Dictionary = {
KERZEN: "Beeswax candles",
PROPOLIS: "Propolis",
},
turnstile: {
loadError: "The security check could not be loaded.",
retry: "Try again",
},
},
nav: {
shop: "Shop",
+5
View File
@@ -46,6 +46,11 @@ export interface Dictionary {
PROPOLIS: string;
};
siteDescription: string;
/** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */
turnstile: {
loadError: string;
retry: string;
};
};
nav: {
shop: string;