@remit/web-client 0.0.116 → 0.0.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.116",
3
+ "version": "0.0.118",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -7,13 +7,12 @@
7
7
  * what wording is not decided here.
8
8
  */
9
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
10
- import { ShellTopBar } from "@remit/ui";
10
+ import { ShellTopBar, shortcutHintForAction } from "@remit/ui";
11
11
  import { useNavigate } from "@tanstack/react-router";
12
12
  import { AccountMenu } from "@/auth/AccountMenu";
13
13
  import { useGlobalCompose } from "@/hooks/useComposeTarget";
14
14
  import { useSearchScope } from "@/hooks/useSearchScope";
15
15
  import { openBugReport } from "@/lib/bug-report";
16
- import { shortcutHintForAction } from "@/lib/keymap";
17
16
  import { useMailContext } from "@/lib/mail-context";
18
17
 
19
18
  interface MailTopBarProps {
@@ -2,9 +2,9 @@ import {
2
2
  IntelligenceToggle,
3
3
  type MailAction,
4
4
  MailActionToolbar,
5
+ tooltipForAction,
5
6
  } from "@remit/ui";
6
7
  import { useState } from "react";
7
- import { tooltipForAction } from "@/lib/keymap";
8
8
  import { MoveToTrigger } from "./MoveToTrigger";
9
9
 
10
10
  /**
@@ -16,7 +16,7 @@
16
16
  * are not rendered: focus stops moving, the highlight disappears, and the next
17
17
  * verb acts on a message the user cannot see.
18
18
  */
19
- import { SelectionTopBar, type Verb } from "@remit/ui";
19
+ import { SelectionTopBar, useListCursor, type Verb } from "@remit/ui";
20
20
  import {
21
21
  createContext,
22
22
  type ReactNode,
@@ -30,7 +30,6 @@ import {
30
30
  } from "react";
31
31
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
32
32
  import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
33
- import { useListCursor } from "@/hooks/useListCursor";
34
33
  import { useIsDesktop } from "@/hooks/useMediaQuery";
35
34
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
36
35
  import { formatDeleteToTrashTitle } from "@/lib/format";
@@ -4,8 +4,8 @@
4
4
  * Once consent is given the server is genuinely going away, so a quiet spinner
5
5
  * over a mailbox that cannot load would let a broken system look healthy. This
6
6
  * mounts at the app root and owns the window for the whole apply, and for the
7
- * "server never came back" verdict — from any route, so a reload mid-apply or a
8
- * second tab resumes straight into it.
7
+ * "server never came back" verdict — from any route, and in any tab the server
8
+ * tells about the run.
9
9
  */
10
10
  import {
11
11
  SelfUpdateProgressOverlay,
@@ -1,7 +1,6 @@
1
- import { Kbd } from "@remit/ui";
1
+ import { Kbd, KEY_HINT_GROUPS } from "@remit/ui";
2
2
  import { X } from "lucide-react";
3
3
  import { Fragment, useCallback, useEffect } from "react";
4
- import { KEY_HINT_GROUPS } from "@/lib/keymap";
5
4
  import { cn } from "@/lib/utils";
6
5
 
7
6
  interface KeyboardShortcutsModalProps {
@@ -11,7 +10,7 @@ interface KeyboardShortcutsModalProps {
11
10
 
12
11
  /**
13
12
  * The `?` help overlay (#429). Renders the full triage key map from the single
14
- * source of truth (`@/lib/keymap`) using the remit-ui `Kbd` component, so the
13
+ * source of truth (`@remit/ui`'s keymap) using the `Kbd` component, so the
15
14
  * displayed bindings can never drift from what the dispatcher actually routes.
16
15
  */
17
16
  export const KeyboardShortcutsModal = ({
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The self-update hook, wired end to end against the generated client through a
3
3
  * mocked fetch. The pure state machine is covered in `lib/self-update-state.test.ts`;
4
- * these exercise the parts only the live hook has: resuming a persisted run
5
- * across a request that fails, the poll that survives a dead server, and the
6
- * POST that persists a run id the reload will pick up.
4
+ * these exercise the parts only the live hook has: what a page claims before the
5
+ * server has answered, the run this page starts and holds, and the poll that
6
+ * survives a dead server.
7
7
  */
8
8
  import assert from "node:assert/strict";
9
9
  import { afterEach, beforeEach, describe, test } from "node:test";
@@ -17,9 +17,8 @@ import { SelfUpdateOverlay } from "../components/self-update/SelfUpdateOverlay";
17
17
  import { AdvancedNavIcon } from "../components/settings/AdvancedNavIcon";
18
18
  import { SelfUpdatePanel } from "../components/settings/SelfUpdatePanel";
19
19
  import {
20
- loadHeldRun,
21
- SELF_UPDATE_RUN_KEY,
22
- saveHeldRun,
20
+ APPLY_BUDGET_SECONDS,
21
+ NEVER_CAME_BACK_MARGIN_SECONDS,
23
22
  } from "../lib/self-update-state";
24
23
  import { createDomHarness, type DomHarness } from "../test-support/dom";
25
24
  import { type HttpMock, httpError, mockFetch } from "../test-support/http";
@@ -32,19 +31,12 @@ import {
32
31
  let harness: DomHarness | undefined;
33
32
  let http: HttpMock | undefined;
34
33
 
35
- function installMemoryStorage(): void {
36
- const store = new Map<string, string>();
37
- globalThis.localStorage = {
38
- getItem: (k: string) => store.get(k) ?? null,
39
- setItem: (k: string, v: string) => void store.set(k, v),
40
- removeItem: (k: string) => void store.delete(k),
41
- clear: () => store.clear(),
42
- key: () => null,
43
- length: 0,
44
- } as Storage;
45
- }
46
-
47
- beforeEach(installMemoryStorage);
34
+ // The browser global, as the app would find it. Present so that "a reload
35
+ // resumes nothing" is a claim about the app rather than about the harness.
36
+ beforeEach(() => {
37
+ globalThis.localStorage = globalThis.window.localStorage;
38
+ localStorage.clear();
39
+ });
48
40
 
49
41
  afterEach(() => {
50
42
  http?.restore();
@@ -84,6 +76,8 @@ const available: RemitImapSystemUpdateResponse = {
84
76
  };
85
77
 
86
78
  const updateKey = systemOperationsGetSystemUpdateQueryKey();
79
+ const BUDGET_MS =
80
+ (APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
87
81
 
88
82
  async function settle(dom: DomHarness): Promise<void> {
89
83
  for (let attempt = 0; attempt < 40; attempt += 1) {
@@ -105,6 +99,55 @@ async function settle(dom: DomHarness): Promise<void> {
105
99
  }
106
100
  }
107
101
 
102
+ /**
103
+ * Mount the provider with a probe on the hook, so a test can drive `install`
104
+ * and read the surface back. The POST answers with a run in flight, which is
105
+ * what the real seam returns the moment it accepts the request.
106
+ */
107
+ function mountApi(
108
+ getResponse: () => unknown,
109
+ children: ReactNode = null,
110
+ ): { dom: DomHarness; api: () => SelfUpdateApi } {
111
+ http = mockFetch((call) => {
112
+ if (call.path.endsWith("/system/update") && call.method === "GET") {
113
+ return getResponse();
114
+ }
115
+ return {
116
+ currentVersion: "0.9.3",
117
+ check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
118
+ run: run({ outcome: null }),
119
+ };
120
+ });
121
+ let captured: SelfUpdateApi | undefined;
122
+ const Probe = () => {
123
+ captured = useSelfUpdate();
124
+ return null;
125
+ };
126
+ harness = createDomHarness();
127
+ harness.renderApp(
128
+ createElement(SelfUpdateProvider, null, createElement(Probe), children),
129
+ );
130
+ return {
131
+ dom: harness,
132
+ api: () => {
133
+ if (!captured) throw new Error("hook not mounted");
134
+ return captured;
135
+ },
136
+ };
137
+ }
138
+
139
+ async function startUpdate(
140
+ dom: DomHarness,
141
+ api: () => SelfUpdateApi,
142
+ ): Promise<void> {
143
+ await act(async () => {
144
+ api().install("0.9.4");
145
+ await dom.flush();
146
+ await dom.wait(1);
147
+ await dom.flush();
148
+ });
149
+ }
150
+
108
151
  async function renderSurface(
109
152
  getResponse: () => unknown,
110
153
  children: ReactNode,
@@ -209,41 +252,122 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
209
252
  assert.match(dom.html(), /Installing Remit 0\.9\.4/);
210
253
  });
211
254
 
212
- test("a held run resumes into applying when the request fails", async () => {
213
- saveHeldRun({
214
- runId: "upd_1",
215
- attemptedVersion: "0.9.4",
216
- previousVersion: "0.9.3",
217
- startedAt: Date.now() - 20_000,
218
- });
219
- const dom = await renderSurface(() => {
220
- throw new Error("connection refused");
255
+ test("a page that has heard nothing yet says nothing at all", async () => {
256
+ http = mockFetch(() => new Promise(() => {}));
257
+ harness = createDomHarness();
258
+ harness.renderApp(
259
+ createElement(
260
+ SelfUpdateProvider,
261
+ null,
262
+ createElement(SelfUpdateOverlay),
263
+ createElement(SelfUpdatePanel),
264
+ ),
265
+ );
266
+ await harness.flush();
267
+ await harness.wait(1);
268
+ await harness.flush();
269
+
270
+ assert.equal(harness.html(), "");
271
+ });
272
+
273
+ test("the page that asked for the update rides out a failed request", async () => {
274
+ let failing = false;
275
+ const { dom, api } = mountApi(() => {
276
+ if (failing) throw new Error("connection refused");
277
+ return available;
221
278
  }, createElement(SelfUpdateOverlay));
279
+ await settle(dom);
280
+ await startUpdate(dom, api);
281
+
282
+ failing = true;
283
+ await act(async () => {
284
+ api().onRetryConnection();
285
+ await dom.flush();
286
+ await dom.wait(1);
287
+ await dom.flush();
288
+ });
289
+
290
+ const surface = api().surface;
291
+ assert.equal(
292
+ surface.status === "ready" &&
293
+ surface.section.status === "applying" &&
294
+ surface.section.phase,
295
+ "reconnecting",
296
+ );
222
297
  assert.match(dom.html(), /Installing Remit 0\.9\.4/);
223
298
  assert.doesNotMatch(dom.html(), /has not answered since the restart/);
224
299
  });
225
300
 
226
- test("the budget elapsing flips a held run to never-came-back", async () => {
227
- saveHeldRun({
228
- runId: "upd_1",
229
- attemptedVersion: "0.9.4",
230
- previousVersion: "0.9.3",
231
- startedAt: Date.now() - 60 * 60_000,
232
- });
233
- const dom = await renderSurface(() => {
234
- throw new Error("connection refused");
301
+ test("a silence past the apply budget stops claiming an install is running", async () => {
302
+ let failing = false;
303
+ const { dom, api } = mountApi(() => {
304
+ if (failing) throw new Error("connection refused");
305
+ return available;
235
306
  }, createElement(SelfUpdateOverlay));
236
- assert.match(dom.html(), /has not answered since the restart/);
237
- assert.match(dom.html(), /remit logs/);
307
+ await settle(dom);
308
+ await startUpdate(dom, api);
309
+
310
+ failing = true;
311
+ const realNow = Date.now;
312
+ Date.now = () => realNow() + BUDGET_MS + 60_000;
313
+ try {
314
+ await act(async () => {
315
+ api().onRetryConnection();
316
+ await dom.flush();
317
+ await dom.wait(1);
318
+ await dom.flush();
319
+ });
320
+ assert.doesNotMatch(dom.html(), /Installing Remit 0\.9\.4/);
321
+ assert.match(dom.html(), /has not answered since the restart/);
322
+ assert.match(dom.html(), /remit logs/);
323
+ } finally {
324
+ Date.now = realNow;
325
+ }
238
326
  });
239
327
 
240
- test("a poll that never answers gives up at the budget too", async () => {
241
- saveHeldRun({
242
- runId: "upd_1",
243
- attemptedVersion: "0.9.4",
244
- previousVersion: "0.9.3",
245
- startedAt: Date.now() - 60 * 60_000,
328
+ test("a run the server reports finished ends the wait", async () => {
329
+ let finished = false;
330
+ const { dom, api } = mountApi(
331
+ () =>
332
+ finished
333
+ ? {
334
+ currentVersion: "0.9.4",
335
+ check: { status: "ok", updateAvailable: false },
336
+ run: run({ outcome: "succeeded" }),
337
+ }
338
+ : available,
339
+ createElement(SelfUpdateOverlay),
340
+ );
341
+ await settle(dom);
342
+ await startUpdate(dom, api);
343
+
344
+ finished = true;
345
+ await act(async () => {
346
+ api().onRetryConnection();
347
+ await dom.flush();
348
+ await dom.wait(1);
349
+ await dom.flush();
246
350
  });
351
+
352
+ const surface = api().surface;
353
+ assert.equal(
354
+ surface.status === "ready" && surface.section.status,
355
+ "succeeded",
356
+ );
357
+ assert.equal(dom.html(), "");
358
+ });
359
+
360
+ test("a reload during the silence resumes nothing", async () => {
361
+ const { dom, api } = mountApi(
362
+ () => available,
363
+ createElement(SelfUpdateOverlay),
364
+ );
365
+ await settle(dom);
366
+ await startUpdate(dom, api);
367
+ assert.match(dom.html(), /Installing Remit 0\.9\.4/);
368
+
369
+ http?.restore();
370
+ harness?.close();
247
371
  http = mockFetch(() => new Promise(() => {}));
248
372
  harness = createDomHarness();
249
373
  harness.renderApp(
@@ -253,65 +377,28 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
253
377
  await harness.wait(1);
254
378
  await harness.flush();
255
379
 
256
- assert.doesNotMatch(harness.html(), /Installing Remit 0\.9\.4/);
257
- assert.match(harness.html(), /has not answered since the restart/);
258
- assert.match(harness.html(), /remit logs/);
259
- assert.equal(loadHeldRun(), null);
380
+ assert.equal(harness.html(), "");
260
381
  });
261
382
  });
262
383
 
263
384
  describe("useSystemUpdate — actions", () => {
264
- function mountApi(getResponse: () => unknown): {
265
- dom: DomHarness;
266
- api: () => SelfUpdateApi;
267
- } {
268
- http = mockFetch((call) => {
269
- if (call.path.endsWith("/system/update") && call.method === "GET") {
270
- return getResponse();
271
- }
272
- return {
273
- currentVersion: "0.9.3",
274
- check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
275
- run: run({ outcome: null }),
276
- };
277
- });
278
- let captured: SelfUpdateApi | undefined;
279
- const Probe = () => {
280
- captured = useSelfUpdate();
281
- return null;
282
- };
283
- harness = createDomHarness();
284
- harness.renderApp(
285
- createElement(SelfUpdateProvider, null, createElement(Probe)),
286
- );
287
- return {
288
- dom: harness,
289
- api: () => {
290
- if (!captured) throw new Error("hook not mounted");
291
- return captured;
292
- },
293
- };
294
- }
295
-
296
- test("install persists the run id the surface returns", async () => {
385
+ test("install takes the run from the surface and writes nothing down", async () => {
297
386
  const { dom, api } = mountApi(() => available);
298
387
  await settle(dom);
388
+ await startUpdate(dom, api);
299
389
 
300
- await act(async () => {
301
- api().install("0.9.4");
302
- await dom.flush();
303
- await dom.wait(1);
304
- await dom.flush();
305
- });
306
-
307
- const held = loadHeldRun();
308
- assert.equal(held?.runId, "upd_1");
309
- assert.equal(localStorage.getItem(SELF_UPDATE_RUN_KEY) !== null, true);
310
390
  const surface = api().surface;
311
391
  assert.equal(
312
392
  surface.status === "ready" && surface.overlay.kind,
313
393
  "applying",
314
394
  );
395
+ assert.equal(
396
+ surface.status === "ready" &&
397
+ surface.section.status === "applying" &&
398
+ surface.section.runId,
399
+ "upd_1",
400
+ );
401
+ assert.equal(localStorage.length, 0);
315
402
  });
316
403
 
317
404
  test("dismissing a finished result clears the pane", async () => {
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * The self-update surface, wired to the generated client.
3
3
  *
4
- * A single instance owns the query, the mutation, and the run id the client
5
- * persists across a restart, and hands the folded `SelfUpdateState` plus the
6
- * blocking-overlay state to every consumer through context — so the Advanced
7
- * pane, the root overlay, and the nav dot all read one source of truth. The
8
- * state machine itself lives in `lib/self-update-state.ts`.
4
+ * A single instance owns the query, the mutation, and the run this page asked
5
+ * for, and hands the folded `SelfUpdateState` plus the blocking-overlay state to
6
+ * every consumer through context — so the Advanced pane, the root overlay, and
7
+ * the nav dot all read one source of truth. The state machine itself lives in
8
+ * `lib/self-update-state.ts`.
9
+ *
10
+ * The held run lives here and nowhere else. A restart does not reload the page,
11
+ * so this state spans the whole window; a page that loads afterwards holds
12
+ * nothing and takes the server's answer as it finds it.
9
13
  *
10
14
  * Polling follows the run: every 30 seconds while idle, every 5 seconds while a
11
- * run is in flight or a persisted run is being resumed.
15
+ * run is in flight or this page is waiting on one it started.
12
16
  */
13
17
  import {
14
18
  systemOperationsApplySystemUpdateMutation,
@@ -33,13 +37,11 @@ import {
33
37
  } from "react";
34
38
  import {
35
39
  appliesSchemaMigration,
36
- clearStoredRun,
37
40
  deriveUpdateSurface,
38
41
  type HeldRun,
39
42
  isSurfaceAbsent,
40
- loadHeldRun,
43
+ mapUpdatePhase,
41
44
  releaseFromCheck,
42
- saveHeldRun,
43
45
  type UpdateSurface,
44
46
  } from "@/lib/self-update-state";
45
47
 
@@ -76,7 +78,7 @@ function pollInterval(
76
78
 
77
79
  export function useSystemUpdate(): SelfUpdateApi {
78
80
  const queryClient = useQueryClient();
79
- const [held, setHeld] = useState<HeldRun | null>(() => loadHeldRun());
81
+ const [held, setHeld] = useState<HeldRun | null>(null);
80
82
  const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
81
83
  const [checkRequested, setCheckRequested] = useState(false);
82
84
 
@@ -112,11 +114,7 @@ export function useSystemUpdate(): SelfUpdateApi {
112
114
  ? derived.surface.section.runId
113
115
  : null;
114
116
 
115
- const { clearStoredRun: shouldClearStored, releaseHeld } = derived;
116
-
117
- useEffect(() => {
118
- if (shouldClearStored) clearStoredRun();
119
- }, [shouldClearStored]);
117
+ const { releaseHeld } = derived;
120
118
 
121
119
  useEffect(() => {
122
120
  if (releaseHeld) setHeld((current) => (current === null ? current : null));
@@ -143,14 +141,13 @@ export function useSystemUpdate(): SelfUpdateApi {
143
141
  onSuccess: (response: RemitImapSystemUpdateResponse) => {
144
142
  const run = response.run;
145
143
  if (run !== null) {
146
- const record: HeldRun = {
144
+ setHeld({
147
145
  runId: run.runId,
148
146
  attemptedVersion: run.targetVersion,
149
147
  previousVersion: run.fromVersion,
148
+ phase: mapUpdatePhase(run.phase),
150
149
  startedAt: Date.now(),
151
- };
152
- saveHeldRun(record);
153
- setHeld(record);
150
+ });
154
151
  setDismissedRunId(null);
155
152
  }
156
153
  queryClient.setQueryData(
@@ -174,7 +171,6 @@ export function useSystemUpdate(): SelfUpdateApi {
174
171
  const onDismissResult = useCallback(() => {
175
172
  if (shownRunIdRef.current !== null)
176
173
  setDismissedRunId(shownRunIdRef.current);
177
- clearStoredRun();
178
174
  setHeld(null);
179
175
  }, []);
180
176
 
@@ -14,12 +14,9 @@
14
14
  * context comes first, the pane builds its handlers from it, and the keyboard
15
15
  * registration comes last.
16
16
  */
17
+ import { type TriageHandlers, useTriageKeyboard } from "@remit/ui";
17
18
  import { type RefObject, useCallback, useRef, useState } from "react";
18
19
  import type { MessageListCommands } from "@/components/mail/MessageList";
19
- import {
20
- type TriageHandlers,
21
- useTriageKeyboard,
22
- } from "@/hooks/useTriageKeyboard";
23
20
  import { adjacentMessageId } from "@/lib/adjacent-message";
24
21
 
25
22
  export interface TriageContextUpdate {