From 4bcdb578e689a7429058a601cace418debb80847 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:31:36 -0500 Subject: [PATCH] fix(display): keep a live display across reconnects, and return a revoked one to pairing Every browser 'online' event reset the display host to its loading state and mounted the module again: a fresh viewer session, a fresh stage, and every cached object URL released, on each Wi-Fi blip. And a device revoked while its display was open never noticed; it kept playing cached content and polling 403s until someone reloaded the page. The host now re-resolves its descriptor in the background (ADR 0043) and only replaces the frame when the answer actually changed: a new version or principal remounts, a refusal returns the screen to pairing (ADR 0036), and any other failure keeps the running display. A module request refused with 401/403 prompts the same revalidation, throttled, so revocation is noticed within one heartbeat. The offline indicator waits ten seconds before appearing, so a momentary blip shows nothing, and no longer names the platform on a lobby wall. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/display/DisplayApp.tsx | 170 +++++++++++-- surfaces/shell/src/display/DisplayUiFrame.tsx | 13 +- surfaces/shell/tests/display-app.test.tsx | 236 ++++++++++++++++++ 3 files changed, 400 insertions(+), 19 deletions(-) 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"); + }); +});