diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx
index e4f291f..dc385d6 100644
--- a/surfaces/shell/src/display/DisplayApp.tsx
+++ b/surfaces/shell/src/display/DisplayApp.tsx
@@ -1,6 +1,6 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
-import { type ReactElement, useEffect, useState } from "react";
+import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
- | { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
+ | {
+ readonly status: "ready";
+ readonly bootstrap: DisplayHostBootstrap & { status: "ready" };
+ /** Changes only when the mounted display must be replaced, never merely because the network
+ * came back. It keys the frame, so it is the one thing that can remount a running display. */
+ readonly generation: number;
+ }
/** No identity this display can use yet: show a pairing code rather than a human login form. */
| { readonly status: "pairing" }
| { readonly status: "unavailable" }
@@ -31,6 +37,54 @@ export interface DisplayAppProps {
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
readonly connectivity?: DisplayConnectivitySource;
+ /** How long the display must stay offline before the corner indicator appears. Test seam only. */
+ readonly offlineIndicatorDelayMs?: number;
+ /** The shortest interval between two revalidations prompted by refused module requests. Test seam
+ * only. */
+ readonly revalidationIntervalMs?: number;
+}
+
+/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
+ * an outage that outlasts this is worth the corner indicator. */
+export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
+
+/** A module whose requests are being refused prompts at most one revalidation in this interval. A
+ * display that is merely missing one permission must not turn every refused request into two more. */
+export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
+
+/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally:
+ * the descriptor and principal are small, server-produced, and carry nothing volatile, so any
+ * difference — a new version, a new contribution, a different principal — is a real one. */
+function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean {
+ return (
+ JSON.stringify([left.descriptor, left.principal]) ===
+ JSON.stringify([right.descriptor, right.principal])
+ );
+}
+
+/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */
+function useSustained(value: boolean, delayMs: number): boolean {
+ const [sustained, setSustained] = useState(value && delayMs <= 0);
+ useEffect(() => {
+ if (!value) {
+ setSustained(false);
+ return;
+ }
+ if (delayMs <= 0) {
+ setSustained(true);
+ return;
+ }
+ const timer = setTimeout(() => setSustained(true), delayMs);
+ return () => clearTimeout(timer);
+ }, [delayMs, value]);
+ return sustained;
+}
+
+function isDenied(error: unknown): boolean {
+ return (
+ error instanceof HostProtocolError &&
+ (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
+ );
}
const displayStyles = {
@@ -86,11 +140,13 @@ const displayStyles = {
* recovers on its own the moment connectivity returns.
*/
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
+ // Neutral wording: this is on a wall the congregation reads, and the product name is the
+ // installation's to choose, not the platform's (ADR 0046).
const label = props.connectivity.usingFallback
? props.connectivity.fallbackKind === "artifact"
? "Offline — showing the last downloaded display code"
- : "Offline — showing the last downloaded Ekklesia display"
- : "Offline — waiting for Ekklesia";
+ : "Offline — showing the last downloaded display"
+ : "Offline — reconnecting";
return (
{label}
@@ -130,7 +186,83 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
online: true,
usingFallback: false,
});
- const { config, connectivity: connectivitySource, importModule, moduleId } = props;
+ const {
+ config,
+ connectivity: connectivitySource,
+ importModule,
+ moduleId,
+ offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
+ revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
+ } = props;
+ const stateRef = useRef(state);
+ stateRef.current = state;
+ const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
+ inFlight: undefined,
+ lastAt: 0,
+ });
+
+ /**
+ * Resolves the descriptor again **without** taking the running display down (ADR 0043).
+ *
+ * A display that is showing something keeps showing it while the question is asked. Only the
+ * answer decides what happens: the same display means nothing changes at all; a new version or a
+ * different principal replaces the frame; a refusal is a revoked or expired device and returns the
+ * screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame
+ * stays. A display that is not showing anything simply starts over.
+ */
+ const revalidate = useCallback(
+ (reason: "online" | "authorization"): void => {
+ const current = stateRef.current;
+ if (current.status !== "ready") {
+ if (reason === "online") {
+ setAttempt((value) => value + 1);
+ }
+ return;
+ }
+ const tracker = revalidation.current;
+ const nowMs = Date.now();
+ if (
+ tracker.inFlight !== undefined ||
+ (reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs)
+ ) {
+ return;
+ }
+ tracker.lastAt = nowMs;
+ const abort = new AbortController();
+ tracker.inFlight = abort;
+ void bootstrapDisplayHost(config, moduleId, abort.signal)
+ .then((next) => {
+ const latest = stateRef.current;
+ if (abort.signal.aborted || latest.status !== "ready") {
+ return;
+ }
+ if (!sameDisplay(latest.bootstrap, next)) {
+ setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" });
+ }
+ })
+ .catch((error: unknown) => {
+ if (!abort.signal.aborted && isDenied(error)) {
+ setState({ status: "pairing" });
+ }
+ })
+ .finally(() => {
+ if (tracker.inFlight === abort) {
+ tracker.inFlight = undefined;
+ }
+ });
+ },
+ [config, moduleId, revalidationIntervalMs],
+ );
+
+ useEffect(
+ () => () => {
+ revalidation.current.inFlight?.abort();
+ revalidation.current.inFlight = undefined;
+ },
+ [],
+ );
+
+ const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
useEffect(() => {
if (!connectivitySource) {
@@ -142,27 +274,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
setConnectivity(next);
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
// display resolves its descriptor again — which is also what stops a screen from sitting on
- // cached code after the server returns, since the fresh descriptor names the current version
- // and the network-first worker fetches it (ADR 0043).
+ // cached code after the server returns, since a fresh descriptor naming a new version replaces
+ // the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the
+ // running display exactly where it was.
if (next.online) {
- setAttempt((current) => current + 1);
+ revalidate("online");
}
});
return () => {
unsubscribe();
monitor.close();
};
- }, [connectivitySource]);
+ }, [connectivitySource, revalidate]);
useEffect(() => {
const abort = new AbortController();
+ revalidation.current.inFlight?.abort();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
- setState({ bootstrap, status: "ready" });
+ setState({ bootstrap, generation: attempt, status: "ready" });
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
@@ -170,10 +304,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
// paired yet, not an error worth logging on every wall in the building.
- if (
- error instanceof HostProtocolError &&
- (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
- ) {
+ if (isDenied(error)) {
setState({ status: "pairing" });
return;
}
@@ -188,12 +319,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
+ const showOffline = useSustained(
+ !connectivity.online || connectivity.usingFallback,
+ offlineIndicatorDelayMs,
+ );
return (
- {connectivity.online && !connectivity.usingFallback ? undefined : (
-
- )}
+ {showOffline ? : undefined}
{state.status === "loading" ? (
Loading display…
) : undefined}
@@ -215,7 +348,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
- key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
+ key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
+ onAuthorizationResponse={onAuthorizationResponse}
principal={state.bootstrap.principal}
/>
) : undefined}
diff --git a/surfaces/shell/src/display/DisplayUiFrame.tsx b/surfaces/shell/src/display/DisplayUiFrame.tsx
index d9a3792..3f458e9 100644
--- a/surfaces/shell/src/display/DisplayUiFrame.tsx
+++ b/surfaces/shell/src/display/DisplayUiFrame.tsx
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
+ /** Host-internal signal that a module request was refused, so the host can find out whether this
+ * device was revoked. It never enters the module-facing context. */
+ readonly onAuthorizationResponse?: (status: 401 | 403) => void;
}
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
@@ -94,7 +97,10 @@ const frameStyles = {
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef(null);
const [status, setStatus] = useState("loading");
- const { config, descriptor, importModule, onRetry, principal } = props;
+ const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props;
+ // Read through a ref so a new callback identity can never remount a running display.
+ const authorizationRef = useRef(onAuthorizationResponse);
+ authorizationRef.current = onAuthorizationResponse;
useEffect(() => {
const container = containerRef.current;
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
context: createModuleUiContext({
config,
descriptor,
+ onAuthorizationResponse: (status) => {
+ if (!cancelled) {
+ authorizationRef.current?.(status);
+ }
+ },
principal,
...(importModule ? { importModule } : {}),
}),
diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx
index a5df29b..9899d4b 100644
--- a/surfaces/shell/tests/display-app.test.tsx
+++ b/surfaces/shell/tests/display-app.test.tsx
@@ -180,6 +180,12 @@ async function render(
importModule?: (url: string) => Promise,
moduleId = "announcements",
connectivity?: DisplayConnectivitySource,
+ timing: {
+ readonly offlineIndicatorDelayMs?: number;
+ readonly revalidationIntervalMs?: number;
+ } = {
+ offlineIndicatorDelayMs: 0,
+ },
): Promise {
await act(async () => {
root.render(
@@ -187,6 +193,7 @@ async function render(
config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})}
+ {...timing}
moduleId={moduleId}
/>,
);
@@ -926,3 +933,232 @@ describe("display host — offline behaviour", () => {
expect(query("display-pairing")).not.toBeNull();
});
});
+
+/**
+ * Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing.
+ *
+ * Before these, every browser `online` event tore the module down and mounted it again (a fresh
+ * viewer session, a fresh stage, every cached object URL released), and a device revoked while its
+ * display was open kept playing until someone reloaded the page.
+ */
+describe("display host — continuity across reconnects and revocation", () => {
+ function controllableConnectivity() {
+ const listeners = new Map void>>();
+ return {
+ source: {
+ addEventListener(type: "online" | "offline", listener: () => void) {
+ const existing = listeners.get(type) ?? new Set();
+ existing.add(listener);
+ listeners.set(type, existing);
+ },
+ initialOnline: true,
+ removeEventListener(type: "online" | "offline", listener: () => void) {
+ listeners.get(type)?.delete(listener);
+ },
+ } satisfies DisplayConnectivitySource,
+ async emit(type: "online" | "offline") {
+ await act(async () => {
+ for (const listener of listeners.get(type) ?? []) {
+ listener();
+ }
+ });
+ },
+ };
+ }
+
+ async function flush(): Promise {
+ await act(async () => {
+ for (let index = 0; index < 5; index += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ });
+ }
+
+ /** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */
+ function changingServer() {
+ let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 };
+ let descriptorCalls = 0;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/v1/module-displays/announcements")) {
+ descriptorCalls += 1;
+ return json(descriptor.body, descriptor.status);
+ }
+ if (url.endsWith("/api/v1/device-pairing/requests")) {
+ return json(
+ { code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
+ 201,
+ );
+ }
+ if (url.endsWith("/api/v1/device-pairing/claims")) {
+ return json({ status: "pending" });
+ }
+ if (url.endsWith("/api/v1/modules/announcements/refused")) {
+ return json({ code: "AUTHORIZATION_DENIED" }, 403);
+ }
+ return json({ code: "NOT_FOUND" }, 404);
+ }) as unknown as typeof globalThis.fetch;
+ return {
+ descriptorCalls: () => descriptorCalls,
+ fetch,
+ set(status: number, body: unknown) {
+ descriptor = { body, status };
+ },
+ };
+ }
+
+ function countingArtifact(version = "1.2.0") {
+ const counts = { mounts: 0, unmounts: 0 };
+ let context: ModuleUiHostContext | undefined;
+ const importModule = async (url: string) =>
+ artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, {
+ mount(element, mounted) {
+ counts.mounts += 1;
+ context = mounted;
+ element.textContent = "live";
+ return () => {
+ counts.unmounts += 1;
+ };
+ },
+ });
+ return { context: () => context, counts, importModule };
+ }
+
+ it("does not remount a live display when the network drops and returns", async () => {
+ const server = changingServer();
+ const connectivity = controllableConnectivity();
+ const display = countingArtifact();
+ await render(
+ { apiBaseUrl, fetch: server.fetch },
+ display.importModule,
+ "announcements",
+ connectivity.source,
+ );
+ await flush();
+ expect(display.counts.mounts).toBe(1);
+ const before = server.descriptorCalls();
+
+ await connectivity.emit("offline");
+ await connectivity.emit("online");
+ await flush();
+
+ // The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing.
+ expect(server.descriptorCalls()).toBeGreaterThan(before);
+ expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
+ expect(query("display-host-loading")).toBeNull();
+ });
+
+ it("keeps the running display when the descriptor cannot be reached yet", async () => {
+ const server = changingServer();
+ const connectivity = controllableConnectivity();
+ const display = countingArtifact();
+ await render(
+ { apiBaseUrl, fetch: server.fetch },
+ display.importModule,
+ "announcements",
+ connectivity.source,
+ );
+ await flush();
+
+ server.set(503, { code: "UNAVAILABLE" });
+ await connectivity.emit("offline");
+ await connectivity.emit("online");
+ await flush();
+
+ expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
+ expect(query("display-host-error")).toBeNull();
+ });
+
+ it("replaces the display when the server now names a new version", async () => {
+ const server = changingServer();
+ const connectivity = controllableConnectivity();
+ const display = countingArtifact();
+ await render(
+ { apiBaseUrl, fetch: server.fetch },
+ display.importModule,
+ "announcements",
+ connectivity.source,
+ );
+ await flush();
+
+ server.set(200, descriptorPayload("announcements", "2.0.0"));
+ await connectivity.emit("offline");
+ await connectivity.emit("online");
+ await flush();
+
+ expect(display.counts.unmounts).toBe(1);
+ expect(display.counts.mounts).toBe(2);
+ });
+
+ it("returns a device revoked while its display is open to pairing, without a reload", async () => {
+ const server = changingServer();
+ const display = countingArtifact();
+ await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements");
+ await flush();
+ expect(query("display-module-container")).not.toBeNull();
+
+ // The administrator revokes the device. Its next module request is refused.
+ server.set(403, { code: "AUTHORIZATION_DENIED" });
+ await act(async () => {
+ await display.context()?.api.request("refused");
+ });
+ await flush();
+
+ expect(display.counts.unmounts).toBe(1);
+ expect(query("display-pairing")).not.toBeNull();
+ });
+
+ it("does not remount when a refused request turns out not to be a revocation", async () => {
+ const server = changingServer();
+ const display = countingArtifact();
+ await render(
+ { apiBaseUrl, fetch: server.fetch },
+ display.importModule,
+ "announcements",
+ undefined,
+ {
+ offlineIndicatorDelayMs: 0,
+ revalidationIntervalMs: 60_000,
+ },
+ );
+ await flush();
+ const before = server.descriptorCalls();
+
+ await act(async () => {
+ await display.context()?.api.request("refused");
+ await display.context()?.api.request("refused");
+ await display.context()?.api.request("refused");
+ });
+ await flush();
+
+ // One revalidation for the burst, and the display stays exactly where it was.
+ expect(server.descriptorCalls()).toBe(before + 1);
+ expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
+ });
+
+ it("does not show the offline indicator for a momentary blip", async () => {
+ const server = changingServer();
+ const connectivity = controllableConnectivity();
+ await render(
+ { apiBaseUrl, fetch: server.fetch },
+ countingArtifact().importModule,
+ "announcements",
+ connectivity.source,
+ { offlineIndicatorDelayMs: 60 },
+ );
+ await flush();
+
+ await connectivity.emit("offline");
+ await connectivity.emit("online");
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ });
+ expect(query("display-offline")).toBeNull();
+
+ await connectivity.emit("offline");
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ });
+ expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
+ });
+});