From 47004db8c3e06212f4e78f7adbda549db31d19f5 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 10 Aug 2026 19:31:33 +0200 Subject: [PATCH] Add a shared boot helper for React islands The helper parses the mount node's dataset, waits for the i18n setup before the first render, and swaps the page to a server-rendered error notice when booting fails. This fixes the account app staying on its loading screen forever when fetching the locale data fails, and gives upcoming server-rendered pages with React islands a common entry point. --- frontend/src/entrypoints/main.tsx | 48 ++++++----------- frontend/src/entrypoints/shared.css | 6 ++- frontend/src/styles/critical-notice.css | 18 +++++++ frontend/src/utils/mountIsland.tsx | 71 +++++++++++++++++++++++++ templates/app.html | 4 ++ translations/en.json | 4 ++ 6 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 frontend/src/styles/critical-notice.css create mode 100644 frontend/src/utils/mountIsland.tsx diff --git a/frontend/src/entrypoints/main.tsx b/frontend/src/entrypoints/main.tsx index 0635b09ab..250a1d482 100644 --- a/frontend/src/entrypoints/main.tsx +++ b/frontend/src/entrypoints/main.tsx @@ -5,24 +5,17 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { TooltipProvider } from "@vector-im/compound-web"; -import { StrictMode, Suspense } from "react"; -import { createRoot } from "react-dom/client"; -import { I18nextProvider } from "react-i18next"; +import { Suspense } from "react"; import * as v from "valibot"; -import ErrorBoundary from "../components/ErrorBoundary"; import LoadingScreen from "../components/LoadingScreen"; -import { setGraphqlEndpoint } from "../graphql"; -import i18n, { setupI18n } from "../i18n"; import { makeRouter } from "../router"; +import { mountIsland } from "../utils/mountIsland"; import "./vendor.css"; import "./shared.css"; -setupI18n(); - -const configSchema = v.object({ +const schema = v.object({ root: v.optional(v.string(), "/"), graphqlEndpoint: v.optional(v.string(), "/graphql"), }); @@ -35,25 +28,14 @@ const queryClient = new QueryClient({ }, }); -const rootElement = document.getElementById("root"); -if (!rootElement) throw new Error("#root element not found"); - -const config = v.parse(configSchema, rootElement.dataset); -setGraphqlEndpoint(config.graphqlEndpoint); -const router = makeRouter(config.root, queryClient); - -createRoot(rootElement).render( - - - - - }> - - - - - - - - , -); +void mountIsland({ + id: "root", + schema, + queryClient, + children: (config) => ( + // The router lazy-loads its routes, so it needs a boundary of its own + }> + + + ), +}); diff --git a/frontend/src/entrypoints/shared.css b/frontend/src/entrypoints/shared.css index bbf532163..caa56aead 100644 --- a/frontend/src/entrypoints/shared.css +++ b/frontend/src/entrypoints/shared.css @@ -1,4 +1,5 @@ -/* Copyright 2024, 2025 New Vector Ltd. +/* Copyright 2025, 2026 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. * Copyright 2024 The Matrix.org Foundation C.I.C. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,6 +8,9 @@ @import "tailwindcss"; +/* Both the SSR templates and the SPA shell need this one */ +@import "../styles/critical-notice.css"; + /* The SSR templates live outside the frontend root, so they aren't picked up by * Tailwind's automatic content detection. */ @source "../../../templates/**/*.html"; diff --git a/frontend/src/styles/critical-notice.css b/frontend/src/styles/critical-notice.css new file mode 100644 index 000000000..542c4a151 --- /dev/null +++ b/frontend/src/styles/critical-notice.css @@ -0,0 +1,18 @@ +/* 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. + */ + +/* Used by the SSR templates and by the React island boot-failure notices, so it + * has to be in a stylesheet both the templates and the SPA bundle pull in. */ +.critical-notice { + color: var(--cpd-color-text-critical-primary); + font: var(--cpd-font-body-md-semibold); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + background-color: var(--cpd-color-bg-critical-subtle); + border: 1px solid var(--cpd-color-border-critical-subtle); + border-radius: var(--cpd-space-2x); + text-align: justify; + padding: var(--cpd-space-4x); +} diff --git a/frontend/src/utils/mountIsland.tsx b/frontend/src/utils/mountIsland.tsx new file mode 100644 index 000000000..77eccb5ad --- /dev/null +++ b/frontend/src/utils/mountIsland.tsx @@ -0,0 +1,71 @@ +// 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. + +import type { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import * as v from "valibot"; +import ErrorBoundary from "../components/ErrorBoundary"; +import { setGraphqlEndpoint } from "../graphql"; +import i18n, { setupI18n } from "../i18n"; + +/** + * The server hands each island its parameters through `data-*` attributes; the + * GraphQL endpoint is always one of them, so the helper can wire it up itself. + */ +type IslandSchema = v.GenericSchema; + +/** + * Boot a React island rendered into a server-rendered page. + * + * The mount element's `data-*` attributes are parsed with `schema`, the locale + * data is loaded, then `children` is rendered inside the shared provider stack. + * Anything going wrong before the first render leaves the page with the + * template's own `#-error` notice instead of a half-mounted island. + */ +export const mountIsland = async ({ + id, + schema, + queryClient, + children, +}: { + /** Id of the mount element; its `#-error` sibling is the fallback */ + id: string; + schema: S; + queryClient: QueryClient; + children: (data: v.InferOutput) => React.ReactNode; +}): Promise => { + const el = document.getElementById(id); + + try { + if (!el) throw new Error(`#${id} element not found`); + + const data = v.parse(schema, el.dataset); + // Set before any query runs, i.e. before the children render + setGraphqlEndpoint(data.graphqlEndpoint); + + // Rendering before the locale data is in would flash the raw keys + await setupI18n(); + + createRoot(el).render( + + + + + {children(data)} + + + + , + ); + } catch (error) { + console.error(`Failed to mount #${id}`, error); + if (el) el.hidden = true; + document.getElementById(`${id}-error`)?.removeAttribute("hidden"); + } +}; diff --git a/templates/app.html b/templates/app.html index 0763b8d12..d5aced6d9 100644 --- a/templates/app.html +++ b/templates/app.html @@ -23,5 +23,9 @@ Please see LICENSE files in the repository root for full details.
+ + diff --git a/translations/en.json b/translations/en.json index e7df4b636..8352d0e96 100644 --- a/translations/en.json +++ b/translations/en.json @@ -450,6 +450,10 @@ "context": "pages/sso.html:28:11-109" } }, + "loading_failed": "Something went wrong while loading the page. Try reloading it.", + "@loading_failed": { + "context": "app.html:28:9-32" + }, "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": {