mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-24 04:14:08 +00:00
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:
6 files changed
+117
-34
No files matched your search
@@ -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>
|
||||
),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user