Render the CAPTCHA widgets with React on server-rendered pages

Instead of provider-specific markup and script tags in the head, the
captcha.form() template macro now emits a placeholder element carrying
the CAPTCHA configuration in data attributes, on which a new entrypoint
mounts the React Captcha component. The widget still injects its hidden
response field inside the form, so the server-side verification flow is
unchanged.

This is the first use of inline React components to progressively
enhance server-rendered pages.
This commit is contained in:
Quentin Gliech
2026-08-07 10:28:09 +02:00
parent 162e024ca0
commit 62ccbdf6f1
5 changed files with 62 additions and 34 deletions
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// Entrypoint which mounts the React `Captcha` component as an inline
// "island" on server-rendered pages. The server templates emit a
// placeholder element carrying the CAPTCHA configuration:
//
// <div data-captcha-service="…" data-captcha-site-key="…"></div>
//
// and include this entrypoint, which mounts the widget onto every such
// element. This is the pattern we intend to use for progressively
// enhancing server-rendered pages with React components.
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import * as v from "valibot";
import { Captcha, CaptchaPlaceholder } from "../components/Captcha";
const schema = v.object({
captchaService: v.picklist([
"recaptcha_v2",
"cloudflare_turnstile",
"hcaptcha",
]),
captchaSiteKey: v.string(),
});
for (const node of document.querySelectorAll<HTMLElement>(
"[data-captcha-service]",
)) {
const config = v.parse(schema, node.dataset);
createRoot(node).render(
<StrictMode>
<Suspense fallback={<CaptchaPlaceholder />}>
<Captcha
config={{
service: config.captchaService,
site_key: config.captchaSiteKey,
}}
/>
</Suspense>
</StrictMode>,
);
}
+1 -2
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
@@ -15,7 +16,6 @@ Please see LICENSE files in the repository root for full details.
{% import "components/errors.html" as errors %}
{% import "components/icon.html" as icon %}
{% import "components/scope.html" as scope %}
{% import "components/captcha.html" as captcha %}
<!DOCTYPE html>
<html lang="{{ lang }}">
@@ -27,7 +27,6 @@ Please see LICENSE files in the repository root for full details.
{{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }}
{{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }}
{{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }}
{{ captcha.head() }}
</head>
<body>
<div class="layout-container{% if consent_page is defined %} consent{% endif %}">
+3 -23
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
@@ -14,28 +15,7 @@ Please see LICENSE files in the repository root for full details.
</div>
</noscript>
{%- if captcha_config.service == "recaptcha_v2" -%}
<div class="g-recaptcha {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- elif captcha_config.service == "cloudflare_turnstile" -%}
<div class="cf-turnstile {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- elif captcha_config.service == "hcaptcha" -%}
<div class="h-captcha {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{{ include_asset('src/entrypoints/captcha.tsx') }}
<div class="{{ class }}" data-captcha-service="{{ captcha_config.service }}" data-captcha-site-key="{{ captcha_config.site_key }}"></div>
{%- endif -%}
{% endmacro %}
{% macro head() -%}
{%- if captcha_config|default(False) -%}
{%- if captcha_config.service == "recaptcha_v2" -%}
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
{%- elif captcha_config.service == "cloudflare_turnstile" -%}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{%- elif captcha_config.service == "hcaptcha" -%}
<script src="https://js.hcaptcha.com/1/api.js?recaptchacompat=off" async defer></script>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{%- endmacro %}
+3
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
@@ -8,6 +9,8 @@ Please see LICENSE files in the repository root for full details.
{% extends "base.html" %}
{% import "components/captcha.html" as captcha %}
{% block content %}
<header class="page-heading">
<div class="icon">
+9 -9
View File
@@ -10,7 +10,7 @@
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:77:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:80:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -79,7 +79,7 @@
},
"email_address": "Email address",
"@email_address": {
"context": "pages/recovery/start.html:34:33-58, pages/register/password.html:40:35-60, pages/upstream_oauth2/do_register.html:115:37-62"
"context": "pages/recovery/start.html:34:33-58, pages/register/password.html:43:35-60, pages/upstream_oauth2/do_register.html:115:37-62"
},
"loading": "Loading…",
"@loading": {
@@ -91,15 +91,15 @@
},
"password": "Password",
"@password": {
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:45:33-53"
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:48:33-53"
},
"password_confirm": "Confirm password",
"@password_confirm": {
"context": "pages/register/password.html:49:33-61"
"context": "pages/register/password.html:52:33-61"
},
"username": "Username",
"@username": {
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:37:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
}
},
"error": {
@@ -149,7 +149,7 @@
"captcha": {
"noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.",
"@noscript": {
"context": "components/captcha.html:13:11-36"
"context": "components/captcha.html:14:11-36"
}
},
"change_password": {
@@ -660,7 +660,7 @@
"register": {
"call_to_login": "Already have an account?",
"@call_to_login": {
"context": "pages/register/index.html:63:35-66, pages/register/password.html:80:33-64",
"context": "pages/register/index.html:63:35-66, pages/register/password.html:83:33-64",
"description": "Displayed on the registration page to suggest to log in instead"
},
"continue_with_email": "Continue with email address",
@@ -678,12 +678,12 @@
},
"heading": "Create an account",
"@heading": {
"context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67"
"context": "pages/register/index.html:21:29-69, pages/register/password.html:21:27-67"
}
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {
"context": "pages/register/password.html:54:35-95, pages/upstream_oauth2/do_register.html:180:35-95"
"context": "pages/register/password.html:57:35-95, pages/upstream_oauth2/do_register.html:180:35-95"
}
},
"registration_token": {