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.
This commit is contained in:
Quentin Gliech committed 2026-09-10 18:50:50 +02:00
1 parent 2091a84b30
commit 47004db8c3
6 files changed
+117 -34

No files matched your search

+15 -33
View File
@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<TooltipProvider>
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<RouterProvider router={router} />
</I18nextProvider>
</Suspense>
</TooltipProvider>
</ErrorBoundary>
</QueryClientProvider>
</StrictMode>,
);
void mountIsland({
id: "root",
schema,
queryClient,
children: (config) => (
// The router lazy-loads its routes, so it needs a boundary of its own
<Suspense fallback={<LoadingScreen />}>
<RouterProvider router={makeRouter(config.root, queryClient)} />
</Suspense>
),
});
+5 -1
View File
@@ -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";
+18
View File
@@ -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);
}
+71
View File
@@ -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<unknown, { graphqlEndpoint: string }>;
/**
* 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 `#<id>-error` notice instead of a half-mounted island.
*/
export const mountIsland = async <S extends IslandSchema>({
id,
schema,
queryClient,
children,
}: {
/** Id of the mount element; its `#<id>-error` sibling is the fallback */
id: string;
schema: S;
queryClient: QueryClient;
children: (data: v.InferOutput<S>) => React.ReactNode;
}): Promise<void> => {
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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<TooltipProvider>
<I18nextProvider i18n={i18n}>{children(data)}</I18nextProvider>
</TooltipProvider>
</ErrorBoundary>
</QueryClientProvider>
</StrictMode>,
);
} catch (error) {
console.error(`Failed to mount #${id}`, error);
if (el) el.hidden = true;
document.getElementById(`${id}-error`)?.removeAttribute("hidden");
}
};
+4
View File
@@ -23,5 +23,9 @@ Please see LICENSE files in the repository root for full details.
<body>
<div id="root" data-root="{{ app_config.root }}" data-graphql-endpoint="{{ app_config.graphqlEndpoint }}"></div>
<div id="root-error" class="critical-notice" hidden>
{{ _("mas.loading_failed") }}
</div>
</body>
</html>
+4
View File
@@ -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": {