The Symptom
The booking form on valkyrienexus.com is a three-step flow: pick a day, pick a time, enter your details. Step 3 holds the Turnstile widget and the submit button. Every single submission failed client-side with "Please complete the verification challenge" -- the error my own code raises when the Turnstile token is empty. Meanwhile the contact form on the same site, using the same site key and the same api.js, verified fine on every submit.
The difference between the two forms had nothing to do with Turnstile configuration. The contact form is in the DOM on page load. The booking form's step 3 lives inside a SolidStart <Show> and only enters the DOM after the visitor clicks through two steps.
The Mechanism
Turnstile's api.js performs a one-time implicit scan for .cf-turnstile elements at the moment the script loads. It does not observe later DOM additions; there is no MutationObserver and no re-scan. If your widget container exists when the script runs, implicit rendering works and you never think about it. If the container mounts later, the scan has already come and gone, the widget never renders, and (the part that makes debugging miserable) the hidden cf-turnstile-response input that carries the token is never created either.
So the form looks complete, the user fills in their details, and submission reads an empty token from an input that does not exist. Nothing errors in the console. The widget is just absent, which is easy to miss when you don't know what a rendered Turnstile is supposed to look like in that spot.
This bites any conditionally rendered UI: a <Show>, a modal, a multi-step form, a tab panel. And it bites React, Vue, and Svelte exactly the same way as Solid, because the failure depends only on when the element enters the DOM.
The Fix
Shipped in PR #24 on 2026-06-08, and it has three parts, all visible in BookingForm.tsx.
First, render explicitly instead of relying on the scan. Solid fires a ref callback when the container element mounts, and that callback calls turnstile.render() directly. Because api.js loads async, the callback polls until the global exists:
const mountTurnstile = (el: HTMLDivElement) => {
turnstileWidgetId = undefined; // fresh element → render anew
const tryRender = () => {
const ts = (window as any).turnstile;
if (!ts) {
window.setTimeout(tryRender, 150);
return;
}
if (turnstileWidgetId !== undefined) return;
turnstileWidgetId = ts.render(el, {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
});
};
tryRender();
};
<div ref={mountTurnstile} />
Second, drop the cf-turnstile class from the container. If the element ever does happen to be in the DOM when the implicit scan runs, you would get a duplicate widget: one from the scan, one from your explicit render. No class means the scan can never claim it.
Third, keep the widget ID and reset that specific widget when a submission fails, so the user gets a fresh challenge instead of a spent token:
(window as any).turnstile?.reset(turnstileWidgetId);
The ref callback fires again each time the <Show> recreates the element (the visitor can go back and change their time slot), which is why the widget ID resets to undefined at the top; a stale ID from a destroyed element is useless.
What I'd Tell Past Me
The implicit .cf-turnstile div is a demo convenience that happens to work on static pages. The moment a widget lives anywhere that mounts after load, treat turnstile.render() as the only API. I use the same Turnstile pattern on every client site, so this fix is now part of the standard port. The explicit render costs about fifteen lines and removes an entire class of "verification randomly fails" reports that are genuinely hard to reproduce from a bug description alone.