From d97ad4ae486fa2c5f2676c644eb1a9284259c8ec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 22:35:07 -0700 Subject: [PATCH 1/5] Move the enrollment poll interval into policy-constants The account page will re-read an approval's result at the Burrow's poll pace, and its import graph may reach only policy-constants, never relay-api. Co-Authored-By: Claude Opus 5.5 --- hosted/server/policy-constants.ts | 6 ++++++ hosted/server/relay-api.ts | 8 +------- hosted/server/tests/relay.test.ts | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index 591e6ce8d..1fe8643f0 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -21,6 +21,12 @@ export const RECENT_LOGIN_WINDOW = `${LOGIN_FRESH_AGE_MS / 60_000} minutes`; // an approval waits for its poll (the account writes it). export const ENROLLMENT_TTL_MS = 10 * 60 * 1000; +// Seconds a Burrow waits between enrollment polls (the relay answers it from +// begin): inside `MIN_ENROLL_POLL_INTERVAL_S`–`MAX_ENROLL_POLL_INTERVAL_S`, or +// every shipped Burrow refuses the begin. The account screen re-reads an +// approval's result at the same pace. +export const ENROLLMENT_POLL_INTERVAL_S = 5; + // The Hosted terms version the sign-in notice names and an account accepts by // continuing: the revision date both policy pages print // (`website/src/components/HostedPolicyLayout.tsx`), which diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index a68a75d36..59ad158df 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -57,7 +57,7 @@ import type { } from "remote-lib-common"; import type { RelayEnv } from "./bindings"; import { allowed } from "./one-time"; -import { ENROLLMENT_TTL_MS } from "./policy-constants"; +import { ENROLLMENT_POLL_INTERVAL_S, ENROLLMENT_TTL_MS } from "./policy-constants"; import { relayRoom } from "./relay-room-contract"; import { relayPushRoutes } from "./relay-push"; import { mintVoiceToken } from "./voice-token"; @@ -87,12 +87,6 @@ export const MAX_SESSIONS_PER_ACCOUNT = 32; /** One live registration challenge per live setup token of the same Burrow (rationale). */ export const MAX_SETUP_CHALLENGES_PER_BURROW = MAX_TOKENS_PER_BURROW; -/** - * Seconds a Burrow waits between polls: inside - * `MIN_ENROLL_POLL_INTERVAL_S`–`MAX_ENROLL_POLL_INTERVAL_S`, or every shipped - * Burrow refuses the begin. - */ -export const ENROLLMENT_POLL_INTERVAL_S = 5; /** * A device code's leading bytes: its expiry, big-endian epoch seconds; the * rest of its 32 are random. diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index cedf9afcb..7c480eb63 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -41,9 +41,8 @@ import { } from "../../../remote-lib-common/test/harness/actors.mjs"; import { ADMIN_EMAIL } from "../entitlement"; import { migrations } from "../migrations"; -import { ENROLLMENT_TTL_MS } from "../policy-constants"; +import { ENROLLMENT_POLL_INTERVAL_S, ENROLLMENT_TTL_MS } from "../policy-constants"; import { - ENROLLMENT_POLL_INTERVAL_S, MAX_BURROWS_PER_ACCOUNT, MAX_PASSKEYS_PER_ACCOUNT, MAX_SESSIONS_PER_ACCOUNT, From 197bf456b1aa28a5a05c77fc6ecc6c445a4ed8f9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 22:35:07 -0700 Subject: [PATCH 2/5] Refetch the account after every mutation and wait out approvals Every account-page mutation now goes through one helper, mutate(), which shows the account as the server has it afterwards instead of patching local state. An approval returns a Pending: the desktop's next poll redeems it, so the page says it is waiting and re-reads at the poll pace until Natural Voice turns on or a new computer is listed, for up to a minute, then says the computer has not finished and leaves the rest to the focus refresh. Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/settle.test.ts | 81 +++++++++++++++++++++ hosted/src/App.tsx | 113 ++++++++++++++++++++--------- hosted/src/settle.ts | 56 ++++++++++++++ 3 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 hosted/server/tests/settle.test.ts create mode 100644 hosted/src/settle.ts diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts new file mode 100644 index 000000000..0d3378a6d --- /dev/null +++ b/hosted/server/tests/settle.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { approvalShows, settle, type AccountState } from "../../src/settle"; + +const computer = (burrowId: string) => ({ burrowId, enrolledAt: "2026-10-20" }); +const state = (on: boolean | null, ids: string[] | null): AccountState => ({ + voice: on === null ? null : { entitled: true, on }, + computers: ids && ids.map(computer), +}); + +describe("approvalShows", () => { + it("waits on a voice approval until Natural Voice is on", () => { + const shows = approvalShows("voice", state(false, []))!; + expect(shows(state(false, []))).toBe(false); + expect(shows(state(null, []))).toBe(false); + expect(shows(state(true, []))).toBe(true); + }); + + it("has nothing to wait on when Natural Voice is already on", () => { + expect(approvalShows("voice", state(true, []))).toBeNull(); + }); + + it("waits on a remote approval until a computer not listed before appears", () => { + const shows = approvalShows("remote", state(false, ["a", "b"]))!; + expect(shows(state(false, ["a", "b"]))).toBe(false); + expect(shows(state(false, ["a"]))).toBe(false); + expect(shows(state(false, null))).toBe(false); + expect(shows(state(false, ["c", "a"]))).toBe(true); + }); + + it("has nothing to compare a remote approval with when no list was read", () => { + expect(approvalShows("remote", state(false, null))).toBeNull(); + }); +}); + +describe("settle", () => { + const options = (cancelled = () => false) => ({ + intervalMs: 5000, + timeoutMs: 20_000, + cancelled, + wait: vi.fn(async () => {}), + }); + + it("reads after each interval until a read shows the change", async () => { + const read = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2); + const opts = options(); + // Never handed a missed read: `toFixed` throws on one. + const done = (value: number) => value.toFixed() === "2"; + await expect(settle(read, done, opts)).resolves.toBe("done"); + expect(read).toHaveBeenCalledTimes(4); + expect(opts.wait).toHaveBeenCalledTimes(4); + expect(opts.wait).toHaveBeenCalledWith(5000); + }); + + it("gives up once the timeout's reads all missed", async () => { + const read = vi.fn(async () => 0); + await expect(settle(read, () => false, options())).resolves.toBe("timeout"); + expect(read).toHaveBeenCalledTimes(4); + }); + + it("stops without reading again once cancelled", async () => { + const unread = vi.fn(async () => 1); + await expect(settle(unread, () => true, options(() => true))).resolves.toBe( + "cancelled", + ); + expect(unread).not.toHaveBeenCalled(); + let cancelled = false; + const read = vi.fn(async () => { + cancelled = true; + return 1; + }); + await expect( + settle(read, () => true, options(() => cancelled)), + ).resolves.toBe("cancelled"); + expect(read).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 2b7816ee1..ced209778 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -28,11 +28,13 @@ import { type Session, } from "./api"; import { + ENROLLMENT_POLL_INTERVAL_S, LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW, TERMS_VERSION, } from "../server/policy-constants"; import { takeEnrollment, type Enrollment } from "./enrollment"; +import { approvalShows, settle, type AccountState } from "./settle"; // The terms version the sign-in notice showed when this tab continued past it, // kept through a provider's redirect until the account's acceptance is @@ -67,6 +69,18 @@ const SUBSCRIBE_REDIRECT_MS = 4000; const SIGN_IN_PARAMS = new URLSearchParams(location.search); const SIGN_IN_OPEN = SIGN_IN_PARAMS.has("signin") || SIGN_IN_PARAMS.has("error"); +/** A change Dormouse on the computer finishes after the page's mutation. */ +interface Pending { + /** Whether the account, as last read, shows the change. */ + shows: (after: AccountState) => boolean; + waiting: string; + done: string; + late: string; +} +// How long the page re-reads for a Pending before saying it has not shown; +// after that the focus refresh picks it up. +const PENDING_TIMEOUT_MS = 60_000; + export function App({ enrollment }: { enrollment: Enrollment | null }) { const [session, setSession] = useState(null); const [enabled, setEnabled] = useState([]); @@ -96,11 +110,14 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const codeInput = useRef(null); const actionPending = useRef(false); const refreshGeneration = useRef(0); + // Bumped by every action and on unmount: stops re-reading for a Pending. + const actionGeneration = useRef(0); const callbackError = useRef( new URLSearchParams(location.search).has("error"), ); - const refresh = useCallback(async () => { + // Undefined when a newer refresh superseded this one. + const refresh = useCallback(async (): Promise => { const generation = ++refreshGeneration.current; const [current, providers] = await Promise.all([ getSession(), @@ -128,6 +145,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { "", enrollingRef.current ? "/enroll" : current ? "/account" : "/login", ); + return { voice, computers: enrolled }; }, []); useEffect(() => { // An auth callback's query parameters (`?error=`) never stay in history. @@ -148,6 +166,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { window.addEventListener("focus", focus); return () => { ++refreshGeneration.current; + ++actionGeneration.current; window.removeEventListener("focus", focus); }; }, [refresh]); @@ -180,9 +199,10 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { return () => window.removeEventListener("hashchange", takeLink); }, []); - async function act(label: string, action: () => Promise) { + async function act(label: string, action: () => Promise) { if (actionPending.current) return; ++refreshGeneration.current; + ++actionGeneration.current; actionPending.current = true; setBusy(label); setError(""); @@ -200,6 +220,29 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setBusy(""); } } + /** + * Runs a mutation of the account, then shows the account as the server now + * has it. A Pending it returns is re-read in the background, at the + * computer's poll pace, until the account shows it. + */ + const mutate = (label: string, action: () => Promise) => + act(label, async () => { + const pending = await action(); + const after = await refresh(); + if (pending) void follow(pending, after); + }); + async function follow(pending: Pending, after: AccountState | undefined) { + if (after && pending.shows(after)) return setNotice(pending.done); + const generation = actionGeneration.current; + setNotice(pending.waiting); + const outcome = await settle(refresh, pending.shows, { + intervalMs: ENROLLMENT_POLL_INTERVAL_S * 1000, + timeoutMs: PENDING_TIMEOUT_MS, + cancelled: () => generation !== actionGeneration.current, + }); + if (outcome !== "cancelled") + setNotice(outcome === "done" ? pending.done : pending.late); + } const sendCode = () => act("email", async () => { await post("email-otp/send-verification-otp", { @@ -216,16 +259,15 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { void sendCode(); return; } - void act("verify", async () => { + void mutate("verify", async () => { noteContinued(); await post("sign-in/email-otp", { email: email.trim(), otp: code }); setCode(""); setEnterCode(false); - await refresh(); }); } const signOut = () => - act("logout", async () => { + mutate("logout", async () => { await post("sign-out"); setSession(null); setAccounts([]); @@ -234,30 +276,50 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setPasskeys(null); setEnterCode(false); setCode(""); - await refresh(); setNotice( "Signed out of this browser. Your other devices stay signed in.", ); }); const turnOffVoice = () => - act("voice-off", async () => { + mutate("voice-off", async () => { try { await turnOffHostedVoice(); } finally { // Closed either way, so a failure's error shows on the page. turnOffDialog.current?.close(); } - setHostedVoice((voice) => voice && { ...voice, on: false }); setNotice("Natural Voice is off. Your computers use the system voice."); }); const approve = (code: string) => - act("approve", async () => { + mutate("approve", async () => { + const voice = enrolling?.purpose === "voice"; + // The computer's next poll redeems the approval: until then neither + // Natural Voice nor the computer shows on the account. + const shows = approvalShows(voice ? "voice" : "remote", { + voice: hostedVoice, + computers, + }); await approveEnrollment(code, enrolling?.purpose); clearEnrollment(); - await refresh(); - setNotice( - `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, - ); + const later = + "Keep Dormouse open on your computer; this page updates when you come back to it."; + if (!shows) + return setNotice( + `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, + ); + return voice + ? { + shows, + waiting: `Approved ${code}. Waiting for Dormouse on your computer to turn on Natural Voice…`, + done: "Natural Voice is on.", + late: `Approved ${code}, but Dormouse on your computer has not turned on Natural Voice yet. ${later}`, + } + : { + shows, + waiting: `Approved ${code}. Waiting for Dormouse on your computer to finish enrolling…`, + done: "Your computer is enrolled.", + late: `Approved ${code}, but Dormouse on your computer has not finished enrolling yet. ${later}`, + }; }); const clearEnrollment = () => { setEnrolling(null); @@ -265,30 +327,9 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { history.replaceState(null, "", session ? "/account" : "/login"); }; const removeOne = (burrowId: string) => - act(`remove-${burrowId}`, async () => { - await removeComputer(burrowId); - setComputers( - (enrolled) => - enrolled?.filter((computer) => computer.burrowId !== burrowId) ?? - null, - ); - // Its passkeys stay, no longer attributed to it. - setPasskeys( - (registered) => - registered?.map((passkey) => - passkey.burrowId === burrowId ? { ...passkey, burrowId: null } : passkey, - ) ?? null, - ); - }); + mutate(`remove-${burrowId}`, () => removeComputer(burrowId)); const removeKey = (credentialId: string) => - act(`passkey-${credentialId}`, async () => { - await removePasskey(credentialId); - setPasskeys( - (registered) => - registered?.filter((passkey) => passkey.credentialId !== credentialId) ?? - null, - ); - }); + mutate(`passkey-${credentialId}`, () => removePasskey(credentialId)); // A lapsed account still sees, and may turn off, a Natural Voice left on. const voiceShown = !!hostedVoice && (hostedVoice.entitled || hostedVoice.on); const fresh = diff --git a/hosted/src/settle.ts b/hosted/src/settle.ts new file mode 100644 index 000000000..83dc2aa88 --- /dev/null +++ b/hosted/src/settle.ts @@ -0,0 +1,56 @@ +import type { Computer, HostedVoice } from "./api"; + +/** The part of the account page's state a change finished elsewhere shows in. */ +export interface AccountState { + voice: HostedVoice | null; + computers: Computer[] | null; +} + +/** + * What an approval's redemption changes on the page, read against the state + * before it: Natural Voice turning on, or a computer not listed before. Null + * when no change could show it: voice already on, or no computers list to + * compare with. + */ +export function approvalShows( + purpose: "remote" | "voice", + before: AccountState, +): ((after: AccountState) => boolean) | null { + if (purpose === "voice") + return before.voice?.on ? null : (after) => after.voice?.on === true; + if (!before.computers) return null; + const known = new Set(before.computers.map((computer) => computer.burrowId)); + return (after) => + !!after.computers?.some((computer) => !known.has(computer.burrowId)); +} + +export type Settled = "done" | "timeout" | "cancelled"; + +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Reads every `intervalMs` until a read satisfies `done`, `timeoutMs` passes, + * or `cancelled()` holds. A read that fails, or resolves `undefined` (one a + * newer read superseded), only waits for the next. + */ +export async function settle( + read: () => Promise, + done: (value: T) => boolean, + options: { + intervalMs: number; + timeoutMs: number; + cancelled: () => boolean; + wait?: (ms: number) => Promise; + }, +): Promise { + const { intervalMs, timeoutMs, cancelled, wait = delay } = options; + for (let waited = 0; waited < timeoutMs; waited += intervalMs) { + await wait(intervalMs); + if (cancelled()) return "cancelled"; + const value = await read().catch(() => undefined); + if (cancelled()) return "cancelled"; + if (value !== undefined && done(value)) return "done"; + } + return "timeout"; +} From 4d124591cacfab14bd2530e032c18ef4d2f861c1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 23:39:20 -0700 Subject: [PATCH 3/5] Keep a mutation's success when the refetch after it fails mutate() re-read the account with a bare refresh(), so a failed reload surfaced through act() as the mutation's own failure: a removed computer stayed listed under "Could not connect", and Turn off Natural Voice showed its "is off" notice beside an error. The re-read now goes through reread(), which reports the failure itself: the page says the change is saved but could not reload, and offers Retry. An approval still waits on its computer through follow(). The page also refreshes when the tab becomes visible, since mobile Safari fires no focus returning from another app. Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/settle.test.ts | 18 +++++++++++++++++- hosted/src/App.tsx | 18 +++++++++++++++--- hosted/src/settle.ts | 17 +++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts index 0d3378a6d..c041c76ec 100644 --- a/hosted/server/tests/settle.test.ts +++ b/hosted/server/tests/settle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { approvalShows, settle, type AccountState } from "../../src/settle"; +import { approvalShows, reread, settle, type AccountState } from "../../src/settle"; const computer = (burrowId: string) => ({ burrowId, enrolledAt: "2026-10-20" }); const state = (on: boolean | null, ids: string[] | null): AccountState => ({ @@ -32,6 +32,22 @@ describe("approvalShows", () => { }); }); +describe("reread", () => { + it("resolves the read, telling nothing failed", async () => { + const failed = vi.fn(); + await expect(reread(async () => 1, failed)).resolves.toBe(1); + expect(failed).not.toHaveBeenCalled(); + }); + + it("reports a failed read itself and resolves, so the mutation stays a success", async () => { + const failed = vi.fn(); + await expect( + reread(() => Promise.reject(new Error("offline")), failed), + ).resolves.toBeUndefined(); + expect(failed).toHaveBeenCalledOnce(); + }); +}); + describe("settle", () => { const options = (cancelled = () => false) => ({ intervalMs: 5000, diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index ced209778..23bc4399f 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -34,7 +34,7 @@ import { TERMS_VERSION, } from "../server/policy-constants"; import { takeEnrollment, type Enrollment } from "./enrollment"; -import { approvalShows, settle, type AccountState } from "./settle"; +import { approvalShows, reread, settle, type AccountState } from "./settle"; // The terms version the sign-in notice showed when this tab continued past it, // kept through a provider's redirect until the account's acceptance is @@ -163,11 +163,18 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { if (!actionPending.current) void refresh().catch((error) => setError(error.message)); }; + // Returning to the tab: mobile Safari fires no focus coming back from + // another app. + const visible = () => { + if (document.visibilityState === "visible") focus(); + }; window.addEventListener("focus", focus); + document.addEventListener("visibilitychange", visible); return () => { ++refreshGeneration.current; ++actionGeneration.current; window.removeEventListener("focus", focus); + document.removeEventListener("visibilitychange", visible); }; }, [refresh]); useEffect(() => { @@ -223,12 +230,17 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { /** * Runs a mutation of the account, then shows the account as the server now * has it. A Pending it returns is re-read in the background, at the - * computer's poll pace, until the account shows it. + * computer's poll pace, until the account shows it. A failed refetch never + * reports the mutation, which succeeded, as failed. */ const mutate = (label: string, action: () => Promise) => act(label, async () => { const pending = await action(); - const after = await refresh(); + const after = await reread(refresh, () => + setError( + "Your change is saved, but this page could not reload to show it. Retry, or come back to this page later.", + ), + ); if (pending) void follow(pending, after); }); async function follow(pending: Pending, after: AccountState | undefined) { diff --git a/hosted/src/settle.ts b/hosted/src/settle.ts index 83dc2aa88..e26f11b41 100644 --- a/hosted/src/settle.ts +++ b/hosted/src/settle.ts @@ -24,6 +24,23 @@ export function approvalShows( !!after.computers?.some((computer) => !known.has(computer.burrowId)); } +/** + * Reads the account after a mutation that succeeded: a failed read calls + * `failed` and resolves `undefined`, never rejecting as if the mutation had + * failed. + */ +export async function reread( + read: () => Promise, + failed: () => void, +): Promise { + try { + return await read(); + } catch { + failed(); + return undefined; + } +} + export type Settled = "done" | "timeout" | "cancelled"; const delay = (ms: number) => From 3fa0edbb5dd34f4faf28bbbc6e88c1a43b6834c1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 21 Oct 2026 00:08:48 -0700 Subject: [PATCH 4/5] Let a newer read replace a status an older one wrote Two messages outlived the reads that contradicted them. The "change is saved, but this page could not reload" alert stayed up after a focus, visibilitychange, or approval re-read succeeded; and an approval's late notice ("has not turned on Natural Voice yet") stayed after a later refresh showed it on. Errors now carry whether a failed read caused them: refresh() throws ReadFailed, and a successful refresh clears only such an error, so an action's own failure stays until the next action. A late approval stays outstanding after its timeout; the first refresh whose account shows it replaces the late notice with the done one. Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/settle.test.ts | 40 +++++++++++++++- hosted/src/App.tsx | 75 +++++++++++++++++++----------- hosted/src/settle.ts | 33 +++++++++++++ 3 files changed, 119 insertions(+), 29 deletions(-) diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts index c041c76ec..9e6fd529f 100644 --- a/hosted/server/tests/settle.test.ts +++ b/hosted/server/tests/settle.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from "vitest"; -import { approvalShows, reread, settle, type AccountState } from "../../src/settle"; +import { + ReadFailed, + afterRead, + approvalShows, + asRead, + pageError, + reread, + settle, + type AccountState, +} from "../../src/settle"; const computer = (burrowId: string) => ({ burrowId, enrolledAt: "2026-10-20" }); const state = (on: boolean | null, ids: string[] | null): AccountState => ({ @@ -32,6 +41,35 @@ describe("approvalShows", () => { }); }); +describe("read errors", () => { + it("marks a failed read's error as one, keeping its message", async () => { + await expect(asRead(Promise.resolve(1))).resolves.toBe(1); + const failed = await asRead(Promise.reject(new Error("offline"))).catch( + (error: unknown) => error, + ); + expect(failed).toBeInstanceOf(ReadFailed); + expect(pageError(failed)).toEqual({ message: "offline", read: true }); + }); + + it("marks an action's error as not a read's", () => { + expect(pageError(new Error("Computer not found."))).toEqual({ + message: "Computer not found.", + read: false, + }); + expect(pageError("thrown")).toEqual({ + message: "Something went wrong. Please try again.", + read: false, + }); + }); + + it("clears only a failed read's error on a successful read", () => { + const action = { message: "Computer not found.", read: false }; + expect(afterRead({ message: "offline", read: true })).toBeNull(); + expect(afterRead(action)).toBe(action); + expect(afterRead(null)).toBeNull(); + }); +}); + describe("reread", () => { it("resolves the read, telling nothing failed", async () => { const failed = vi.fn(); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 23bc4399f..ecf9c37fc 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -34,7 +34,16 @@ import { TERMS_VERSION, } from "../server/policy-constants"; import { takeEnrollment, type Enrollment } from "./enrollment"; -import { approvalShows, reread, settle, type AccountState } from "./settle"; +import { + afterRead, + approvalShows, + asRead, + pageError, + reread, + settle, + type AccountState, + type PageError, +} from "./settle"; // The terms version the sign-in notice showed when this tab continued past it, // kept through a provider's redirect until the account's acceptance is @@ -102,7 +111,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { }; const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [notice, setNotice] = useState(""); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); @@ -112,6 +121,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const refreshGeneration = useRef(0); // Bumped by every action and on unmount: stops re-reading for a Pending. const actionGeneration = useRef(0); + // A Pending whose late notice is up: a later read that shows it replaces it. + const latePending = useRef(null); const callbackError = useRef( new URLSearchParams(location.search).has("error"), ); @@ -119,14 +130,13 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { // Undefined when a newer refresh superseded this one. const refresh = useCallback(async (): Promise => { const generation = ++refreshGeneration.current; - const [current, providers] = await Promise.all([ - getSession(), - getProviders(), - ]); + const [current, providers] = await asRead( + Promise.all([getSession(), getProviders()]), + ); // A failed list hides its section, never the account page. const [linked, voice, enrolled, registered] = current ? await Promise.all([ - getAccounts(), + asRead(getAccounts()), getHostedVoice().catch(() => null), getComputers().catch(() => null), getPasskeys().catch(() => null), @@ -134,6 +144,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { : [[], null, null, null]; if (current) await recordContinued().catch(() => {}); if (generation !== refreshGeneration.current) return; + setError(afterRead); setSession(current); setEnabled(providers); setAccounts(linked); @@ -145,23 +156,31 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { "", enrollingRef.current ? "/enroll" : current ? "/account" : "/login", ); - return { voice, computers: enrolled }; + const state = { voice, computers: enrolled }; + const late = latePending.current; + if (late?.shows(state)) { + latePending.current = null; + setNotice(late.done); + } + return state; }, []); useEffect(() => { // An auth callback's query parameters (`?error=`) never stay in history. history.replaceState(null, "", location.pathname); void refresh() - .catch((error) => setError(error.message)) + .catch((error) => setError(pageError(error))) .finally(() => { setLoading(false); if (callbackError.current) - setError( - "Sign-in or connection was not completed. Try again, or sign in with your existing method and connect the provider from your account.", - ); + setError({ + message: + "Sign-in or connection was not completed. Try again, or sign in with your existing method and connect the provider from your account.", + read: false, + }); }); const focus = () => { if (!actionPending.current) - void refresh().catch((error) => setError(error.message)); + void refresh().catch((error) => setError(pageError(error))); }; // Returning to the tab: mobile Safari fires no focus coming back from // another app. @@ -199,7 +218,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const next = takeEnrollment(); if (!next) return; setEnrolling(next); - setError(""); + setError(null); setNotice(""); }; window.addEventListener("hashchange", takeLink); @@ -210,18 +229,15 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { if (actionPending.current) return; ++refreshGeneration.current; ++actionGeneration.current; + latePending.current = null; actionPending.current = true; setBusy(label); - setError(""); + setError(null); setNotice(""); try { await action(); } catch (error) { - setError( - error instanceof Error - ? error.message - : "Something went wrong. Please try again.", - ); + setError(pageError(error)); } finally { actionPending.current = false; setBusy(""); @@ -237,9 +253,11 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { act(label, async () => { const pending = await action(); const after = await reread(refresh, () => - setError( - "Your change is saved, but this page could not reload to show it. Retry, or come back to this page later.", - ), + setError({ + message: + "Your change is saved, but this page could not reload to show it. Retry, or come back to this page later.", + read: true, + }), ); if (pending) void follow(pending, after); }); @@ -252,8 +270,9 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { timeoutMs: PENDING_TIMEOUT_MS, cancelled: () => generation !== actionGeneration.current, }); - if (outcome !== "cancelled") - setNotice(outcome === "done" ? pending.done : pending.late); + if (outcome === "cancelled") return; + if (outcome === "timeout") latePending.current = pending; + setNotice(outcome === "done" ? pending.done : pending.late); } const sendCode = () => act("email", async () => { @@ -335,7 +354,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { }); const clearEnrollment = () => { setEnrolling(null); - setError(""); + setError(null); history.replaceState(null, "", session ? "/account" : "/login"); }; const removeOne = (burrowId: string) => @@ -395,7 +414,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { <> {error && (
-

{error}

+

{error.message}