Files
Ekklesia/surfaces/shell/src/browser/ModuleUiFrame.tsx
T
ekklesia a8c7c50b4d
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s
feat: add browser auth shell
2026-08-23 22:01:26 -05:00

134 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
type FrameStatus = "loading" | "ready" | "failed";
export interface ModuleUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Host-internal session signal. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
* the stale-load rule. Production composition leaves it unset and the loader's own
* `(url) => import(url)` runs. */
readonly importModule?: ModuleUiImporter;
}
/**
* Mounts exactly one module UI into a container this component owns, and tears it down again.
*
* ## Host-owned container, module-owned contents
*
* The `<div>` below belongs to the shell. The module fills it and must return an unmount that
* empties it; it never receives the shell's header, the module list, the page root, or the document.
* The loading and error states are rendered as siblings, so a module can never own the shell's
* pre-mount or failure state — which is the point of the host owning them.
*
* ## One frame per selection, which is what defeats the stale-load race
*
* The parent renders this with a `key` of the module's identity, so selecting a different module
* unmounts this component entirely and creates a fresh one with a **fresh container element**. A
* slow import that resolves after the selection changed therefore mounts into a node that is already
* detached from the document and is immediately unmounted by this component's own cleanup — it can
* never render over, or into, the newer selection's container. The `cancelled` flag makes that
* explicit rather than incidental: a load that finishes after cleanup ran is torn down at once and
* never reaches `setStatus`, so it also cannot resurrect a stale status on a component React has
* already discarded.
*
* ## Failure is the host's to render
*
* Every way this can fail — the artifact will not fetch, the bundle is not a module, its declared
* identity does not match the manifest, or `mount` throws — is caught here and turned into a fixed,
* host-owned message. Nothing from the caught error reaches the DOM: not a URL, not a path, not a
* stack. The error object goes to the browser console, which is where a developer's diagnostics
* belong and where ADR 0024 intends the loader's `cause` to be read. The rest of the shell keeps
* rendering, so a broken module costs its own panel and nothing else.
*/
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
const reportAuthorizationResponse = (status: 401 | 403): void => {
// A module may retain an in-flight request while logout or another auth transition unmounts
// this frame. Its eventual response still belongs to that module call, but must not be allowed
// to cancel or overwrite the newer host transition.
if (!cancelled) {
onAuthorizationResponse?.(status);
}
};
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({
config,
descriptor,
principal,
...(onAuthorizationResponse
? { onAuthorizationResponse: reportAuthorizationResponse }
: {}),
}),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
url: moduleUiSourceUrl(config, descriptor),
version: descriptor.version,
},
});
if (cancelled) {
// Selection already moved on. Release it immediately rather than leaving a module running
// in a detached container with no handle to stop it.
await instance.unmount().catch(() => undefined);
return;
}
mounted = instance;
setStatus("ready");
} catch (error) {
console.error("[ekklesia] a module UI failed to load", error);
if (!cancelled) {
setStatus("failed");
}
}
})();
return () => {
cancelled = true;
// Idempotent by contract (ADR 0024), and a throwing cleanup is swallowed here: teardown is
// already in progress, and a module that fails to clean up must not take the shell with it.
void mounted?.unmount().catch(() => undefined);
};
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
return (
<section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}>
{status === "loading" ? (
<p data-testid="module-loading" style={{ color: "#57534e", margin: 0 }}>
Loading module…
</p>
) : undefined}
{status === "failed" ? (
<p data-testid="module-error" style={{ color: "#9f1239", margin: 0 }}>
This module’s interface could not be loaded. Try another module, or ask an administrator
to check the installation.
</p>
) : undefined}
<div data-testid="module-container" ref={containerRef} />
</section>
);
}