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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06bad2fe36
commit
4bcdb578e6
@@ -180,6 +180,12 @@ async function render(
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
moduleId = "announcements",
|
||||
connectivity?: DisplayConnectivitySource,
|
||||
timing: {
|
||||
readonly offlineIndicatorDelayMs?: number;
|
||||
readonly revalidationIntervalMs?: number;
|
||||
} = {
|
||||
offlineIndicatorDelayMs: 0,
|
||||
},
|
||||
): Promise<void> {
|
||||
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<string, Set<() => 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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user