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
@@ -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 (
|
||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||
{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 (
|
||||
<div data-testid="display-host" style={displayStyles.host}>
|
||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
||||
<OfflineIndicator connectivity={connectivity} />
|
||||
)}
|
||||
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
||||
{state.status === "loading" ? (
|
||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||
) : 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}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("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 } : {}),
|
||||
}),
|
||||
|
||||
@@ -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