@remit/web-client 0.0.206 → 0.0.208

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.206",
3
+ "version": "0.0.208",
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": {
@@ -65,8 +65,40 @@ const FOLDERS = [
65
65
  makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
66
66
  ];
67
67
 
68
+ // The desktop picker renders through a portal into document.body, so its
69
+ // content sits outside the render container these helpers normally scope to.
70
+ const pickerRoot = (dom: DomHarness): ParentNode => {
71
+ const trigger = dom.byLabel("Move to folder");
72
+ return trigger.getAttribute("aria-haspopup") === "dialog"
73
+ ? dom.container
74
+ : dom.document.body;
75
+ };
76
+
68
77
  const rowText = (dom: DomHarness): string[] =>
69
- dom.queryAll("[role=treeitem]").map((row) => row.textContent ?? "");
78
+ [...pickerRoot(dom).querySelectorAll("[role=treeitem]")].map(
79
+ (row) => row.textContent ?? "",
80
+ );
81
+
82
+ const pickerText = (dom: DomHarness): string =>
83
+ pickerRoot(dom).textContent ?? "";
84
+
85
+ const pickerByLabel = (dom: DomHarness, label: string): HTMLElement => {
86
+ const found = pickerRoot(dom).querySelector(`[aria-label="${label}"]`);
87
+ if (!found) throw new Error(`no picker element labelled "${label}"`);
88
+ return found as HTMLElement;
89
+ };
90
+
91
+ const pickerByText = (
92
+ dom: DomHarness,
93
+ selector: string,
94
+ text: string,
95
+ ): HTMLElement => {
96
+ const found = [...pickerRoot(dom).querySelectorAll(selector)].find((node) =>
97
+ (node.textContent ?? "").includes(text),
98
+ );
99
+ if (!found) throw new Error(`no picker ${selector} containing "${text}"`);
100
+ return found as HTMLElement;
101
+ };
70
102
 
71
103
  describe("MoveToTrigger", () => {
72
104
  it("reports itself as collapsed until it is opened", () => {
@@ -103,7 +135,7 @@ describe("MoveToTrigger", () => {
103
135
  false,
104
136
  );
105
137
 
106
- dom.click(dom.byLabel("Move to Work"));
138
+ dom.click(pickerByLabel(dom, "Move to Work"));
107
139
  assert.ok(rowText(dom).some((label) => label.includes("Clients")));
108
140
  });
109
141
 
@@ -117,10 +149,10 @@ describe("MoveToTrigger", () => {
117
149
 
118
150
  // Picking a folder also opens it, so the move is a separate press —
119
151
  // otherwise the first tap would fire before anything nested was reachable.
120
- dom.click(dom.byLabel("Move to Work"));
152
+ dom.click(pickerByLabel(dom, "Move to Work"));
121
153
  assert.deepEqual(moved, []);
122
154
 
123
- dom.click(dom.byText("button", "Move to Work"));
155
+ dom.click(pickerByText(dom, "button", "Move to Work"));
124
156
  assert.deepEqual(moved, ["mbx-work"]);
125
157
  assert.equal(
126
158
  dom.byLabel("Move to folder").getAttribute("aria-expanded"),
@@ -131,10 +163,10 @@ describe("MoveToTrigger", () => {
131
163
  it("names the destination on the button that runs the move", () => {
132
164
  const dom = mount({ mailboxes: FOLDERS });
133
165
  dom.click(dom.byLabel("Move to folder"));
134
- dom.click(dom.byLabel("Move to Work"));
135
- dom.click(dom.byLabel("Move to Clients"));
166
+ dom.click(pickerByLabel(dom, "Move to Work"));
167
+ dom.click(pickerByLabel(dom, "Move to Clients"));
136
168
 
137
- assert.match(dom.text(), /Move to Clients/);
169
+ assert.match(pickerText(dom), /Move to Clients/);
138
170
  });
139
171
 
140
172
  it("closes on Escape and on a click outside it", () => {
@@ -159,6 +191,56 @@ describe("MoveToTrigger", () => {
159
191
  assert.equal(isOpen(), false);
160
192
  });
161
193
 
194
+ it("renders the desktop picker outside its own subtree (#601)", () => {
195
+ // Regression: an in-place absolute popover is clipped by the reading
196
+ // pane's overflow-hidden shell and painted underneath the thread list.
197
+ const dom = mount({ mailboxes: FOLDERS });
198
+ dom.click(dom.byLabel("Move to folder"));
199
+
200
+ const trigger = dom.byLabel("Move to folder");
201
+ const tree = pickerRoot(dom).querySelector("[role=tree]");
202
+ assert.ok(tree, "the picker is open");
203
+ assert.ok(
204
+ !trigger.parentElement?.contains(tree),
205
+ "the picker escapes the trigger's subtree via a portal",
206
+ );
207
+ });
208
+
209
+ it("stays open when the press lands inside the portalled panel (#601)", () => {
210
+ // The panel is not in the trigger's subtree any more, so the outside-press
211
+ // guard has to recognise it by its own ref — otherwise the first press on
212
+ // a folder closes the picker before it can be picked.
213
+ const dom = mount({ mailboxes: FOLDERS });
214
+ dom.click(dom.byLabel("Move to folder"));
215
+
216
+ dom.dispatch(
217
+ pickerByLabel(dom, "Move to Work"),
218
+ new dom.window.MouseEvent("mousedown", { bubbles: true }),
219
+ );
220
+
221
+ assert.equal(
222
+ dom.byLabel("Move to folder").getAttribute("aria-expanded"),
223
+ "true",
224
+ );
225
+ });
226
+
227
+ it("takes the keyboard into the picker and hands it back on close", () => {
228
+ // Regression: with the panel on the body, Tab from the trigger walked on
229
+ // to the next toolbar button instead of entering the picker.
230
+ const dom = mount({ mailboxes: FOLDERS });
231
+ dom.click(dom.byLabel("Move to folder"));
232
+
233
+ const filter = pickerRoot(dom).querySelector('input[type="search"]');
234
+ assert.ok(filter, "the picker has a filter field");
235
+ assert.equal(dom.document.activeElement, filter);
236
+
237
+ dom.dispatch(
238
+ dom.window.document,
239
+ new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
240
+ );
241
+ assert.equal(dom.document.activeElement, dom.byLabel("Move to folder"));
242
+ });
243
+
162
244
  it("refuses to open, and says why, when the selection spans accounts", () => {
163
245
  const hint = "Select messages from one account to move them";
164
246
  const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
@@ -212,6 +294,6 @@ describe("MoveToTrigger", () => {
212
294
  dom.click(dom.byLabel("Move to folder"));
213
295
  // No cached mailboxes and no network in the test harness: the picker
214
296
  // shows its loading state rather than an empty list of destinations.
215
- assert.match(dom.text(), /Loading folders/);
297
+ assert.match(pickerText(dom), /Loading folders/);
216
298
  });
217
299
  });
@@ -1,5 +1,11 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { Button, cn, type FolderTreeNode, FolderTreePicker } from "@remit/ui";
2
+ import {
3
+ Button,
4
+ cn,
5
+ type FolderTreeNode,
6
+ FolderTreePicker,
7
+ PopoverMenuPortal,
8
+ } from "@remit/ui";
3
9
  import { useQuery } from "@tanstack/react-query";
4
10
  import { FolderInput } from "lucide-react";
5
11
  import {
@@ -75,6 +81,8 @@ export const MoveToTrigger = ({
75
81
  const [pickedId, setPickedId] = useState<string>();
76
82
  const isDesktop = useIsDesktop();
77
83
  const containerRef = useRef<HTMLDivElement>(null);
84
+ const panelRef = useRef<HTMLDivElement>(null);
85
+ const triggerRef = useRef<HTMLButtonElement>(null);
78
86
  const triggerLabel = label ?? "Move to folder";
79
87
  const popoverId = useId();
80
88
  const { t } = useTranslation("mail", { useSuspense: false });
@@ -119,6 +127,13 @@ export const MoveToTrigger = ({
119
127
  const close = useCallback(() => {
120
128
  setIsOpen(false);
121
129
  setPickedId(undefined);
130
+ // The panel is about to unmount. Where the keyboard was inside it, hand
131
+ // focus back to the trigger rather than dropping it on the body; where it
132
+ // was elsewhere — a press that landed outside — leave it where it is.
133
+ const active = document.activeElement;
134
+ if (active && panelRef.current?.contains(active)) {
135
+ triggerRef.current?.focus();
136
+ }
122
137
  }, []);
123
138
 
124
139
  // Tapping a folder both picks it and opens it, so the move waits for a
@@ -134,12 +149,13 @@ export const MoveToTrigger = ({
134
149
  useEffect(() => {
135
150
  if (!isOpen || !isDesktop) return;
136
151
  const handlePointer = (event: MouseEvent) => {
137
- if (
138
- containerRef.current &&
139
- !containerRef.current.contains(event.target as Node)
140
- ) {
141
- close();
142
- }
152
+ const target = event.target as Node;
153
+ if (containerRef.current?.contains(target)) return;
154
+ // The picker renders through a portal into document.body, so it
155
+ // sits outside the trigger's subtree — count presses on it as
156
+ // inside, or picking a folder would close the picker first.
157
+ if (panelRef.current?.contains(target)) return;
158
+ close();
143
159
  };
144
160
  // The popover owns Escape while it is open (#732). The global triage
145
161
  // layer listens on window and maps Escape to `back`, which closes the
@@ -159,11 +175,15 @@ export const MoveToTrigger = ({
159
175
  };
160
176
  }, [isOpen, isDesktop, close]);
161
177
 
178
+ const anchorRect = () =>
179
+ containerRef.current?.getBoundingClientRect() ?? null;
180
+
162
181
  const isTriggerDisabled = disabled || !!disabledHint;
163
182
 
164
183
  const TriggerButton = (
165
184
  <button
166
185
  type="button"
186
+ ref={triggerRef}
167
187
  onClick={(event) => {
168
188
  event.stopPropagation();
169
189
  if (isTriggerDisabled) return;
@@ -204,6 +224,10 @@ export const MoveToTrigger = ({
204
224
  onSelect={setPickedId}
205
225
  onCreateFolder={createFolderIn}
206
226
  onCancel={close}
227
+ // The desktop panel is portalled onto the body, so Tab from the
228
+ // trigger would otherwise walk on to the next toolbar button. The
229
+ // drawer runs its own focus and must not raise a phone keyboard.
230
+ autoFocusFilter={isDesktop}
207
231
  labels={{
208
232
  filterPlaceholder: t("move_picker_filter_placeholder", {
209
233
  defaultValue: "Filter folders…",
@@ -260,18 +284,27 @@ export const MoveToTrigger = ({
260
284
  return (
261
285
  <div ref={containerRef} className="relative inline-block">
262
286
  {TriggerButton}
263
- {isOpen && (
287
+ {/* Portalled + fixed-positioned: an in-place absolute popover is
288
+ clipped by the reading pane's overflow-hidden shell and ends up
289
+ painted underneath the thread list (#601). */}
290
+ <PopoverMenuPortal
291
+ open={isOpen}
292
+ align="end"
293
+ panelRef={panelRef}
294
+ getAnchor={anchorRect}
295
+ >
264
296
  <div
265
297
  id={popoverId}
298
+ ref={panelRef}
266
299
  className={cn(
267
- "absolute right-0 mt-1 z-50 w-72 max-h-96 flex flex-col",
300
+ "w-72 max-h-96 flex flex-col",
268
301
  "bg-surface border border-line rounded-md shadow-lg",
269
302
  )}
270
303
  >
271
304
  {pickerBody}
272
305
  {confirmBar}
273
306
  </div>
274
- )}
307
+ </PopoverMenuPortal>
275
308
  </div>
276
309
  );
277
310
  };
@@ -25,6 +25,12 @@ interface TestConnectionResult {
25
25
 
26
26
  const OK: TestConnectionResult = { imapSuccess: true, smtpSuccess: true };
27
27
 
28
+ /**
29
+ * Same-origin and hash-only: jsdom implements that navigation, so the redirect
30
+ * the wizard starts runs for real instead of logging an unimplemented one.
31
+ */
32
+ const CONSENT_URL = "http://localhost/#microsoft-consent";
33
+
28
34
  interface Backend {
29
35
  test?: TestConnectionResult;
30
36
  syncPhase?: string;
@@ -39,6 +45,9 @@ const cancelled: string[] = [];
39
45
  const start = (backend: Backend = {}, skipWelcome = false): DomHarness => {
40
46
  http = mockFetch((call) => {
41
47
  if (call.path === "/accounts/test-connection") return backend.test ?? OK;
48
+ if (call.path.endsWith("/oauth/microsoft/start")) {
49
+ return { authorizationUrl: CONSENT_URL };
50
+ }
42
51
  if (call.path === "/accounts") return { accountId: "acc-new" };
43
52
  if (call.path === "/config") {
44
53
  return {
@@ -277,6 +286,95 @@ describe("OnboardingWizard — a connection that does not work", () => {
277
286
  });
278
287
  });
279
288
 
289
+ /** Enough rounds for the config read, the start call and its redirect. */
290
+ const settle = async (dom: DomHarness): Promise<void> => {
291
+ for (let round = 0; round < 4; round += 1) {
292
+ await dom.flush();
293
+ await dom.wait(20);
294
+ }
295
+ };
296
+
297
+ /** Welcome → Connector → the Microsoft step, with the redirect started. */
298
+ const walkToRedirect = async (dom: DomHarness): Promise<void> => {
299
+ clickText(dom, "Add your first account");
300
+ clickText(dom, "Outlook / Microsoft 365");
301
+ clickText(dom, "Continue with Microsoft");
302
+ await settle(dom);
303
+ clickText(dom, "Sign in with Microsoft");
304
+ await settle(dom);
305
+ };
306
+
307
+ /**
308
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
309
+ * shared environment deliberately does not — so a spec that means "the window
310
+ * is being looked at" has to say so.
311
+ */
312
+ const setVisibility = (state: "hidden" | "visible"): void => {
313
+ Object.defineProperty(document, "visibilityState", {
314
+ configurable: true,
315
+ get: () => state,
316
+ });
317
+ };
318
+
319
+ /** jsdom has no `PageTransitionEvent`; `persisted` is what the hook reads. */
320
+ const pageShow = (persisted: boolean): Event => {
321
+ const event = new Event("pageshow");
322
+ Object.defineProperty(event, "persisted", { value: persisted });
323
+ return event;
324
+ };
325
+
326
+ describe("OnboardingWizard — the Microsoft redirect and the way back (#646)", () => {
327
+ afterEach(() => {
328
+ Reflect.deleteProperty(document, "visibilityState");
329
+ });
330
+
331
+ it("stays busy while the redirect is only being looked at", async () => {
332
+ const dom = start();
333
+ await walkToRedirect(dom);
334
+
335
+ assert.match(dom.text(), /Redirecting…/);
336
+
337
+ // An app-switch away and back during the `assign` fetch: the window is
338
+ // looked at again without ever having left.
339
+ setVisibility("visible");
340
+ dom.dispatch(dom.document, new Event("visibilitychange"));
341
+ dom.dispatch(dom.window, pageShow(false));
342
+ await settle(dom);
343
+
344
+ assert.match(
345
+ dom.text(),
346
+ /Redirecting…/,
347
+ "a look at a window that never left un-busied the button",
348
+ );
349
+ });
350
+
351
+ it("re-arms the button once the window has been away and come back", async () => {
352
+ const dom = start();
353
+ await walkToRedirect(dom);
354
+
355
+ setVisibility("hidden");
356
+ dom.dispatch(dom.document, new Event("visibilitychange"));
357
+ dom.dispatch(dom.window, new Event("pagehide"));
358
+ setVisibility("visible");
359
+ dom.dispatch(dom.document, new Event("visibilitychange"));
360
+ await settle(dom);
361
+
362
+ assert.doesNotMatch(dom.text(), /Redirecting…/);
363
+ assert.match(dom.text(), /Start over/);
364
+ });
365
+
366
+ it("re-arms the button on a page restored from the back-forward cache", async () => {
367
+ const dom = start();
368
+ await walkToRedirect(dom);
369
+
370
+ dom.dispatch(dom.window, pageShow(true));
371
+ await settle(dom);
372
+
373
+ assert.doesNotMatch(dom.text(), /Redirecting…/);
374
+ assert.match(dom.text(), /Start over/);
375
+ });
376
+ });
377
+
280
378
  describe("OnboardingWizard — a sync that stalls", () => {
281
379
  it("says the account is still there and offers a retry", async () => {
282
380
  const dom = start({
@@ -43,7 +43,7 @@ import {
43
43
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
44
44
  import { AtSign, Inbox, Loader2, Mail, Server } from "lucide-react";
45
45
  import { useCallback, useEffect, useRef, useState } from "react";
46
- import { useReturnFromRedirect } from "../../hooks/useReturnFromRedirect.js";
46
+ import { useRedirectEnded } from "../../hooks/useRedirectEnded.js";
47
47
  // useRef is kept for the hasCreatedRef guard — not for DOM refs
48
48
  import {
49
49
  type DiscoveryResult,
@@ -303,14 +303,43 @@ function StepMicrosoftEmail({
303
303
  configOperationsGetConfigOptions(),
304
304
  );
305
305
 
306
+ // Microsoft's answer comes back to whichever window the platform picks, and
307
+ // on iOS that is often the browser rather than the app launched from the
308
+ // home screen. So this window decides on the account list rather than on
309
+ // having been the one that got the redirect: an account that was not there
310
+ // before carries the wizard forward.
311
+ //
312
+ // Every return leg is a chance to find that account, not a verdict on the
313
+ // sign-in — a user who comes back mid-flow to read a password has not failed
314
+ // anything, so nothing here concludes and the check stays armed until an
315
+ // account appears or the user leaves the step.
316
+ const markRedirectStarted = useRedirectEnded(() => {
317
+ setPreparing(false);
318
+ void refetchConfig().then(({ data, isError }) => {
319
+ if (!stepIsMounted.current) return;
320
+ if (isError || !data) {
321
+ setError(
322
+ "Couldn't check whether the sign-in finished. Open Settings › Accounts to see whether the account is connected.",
323
+ );
324
+ return;
325
+ }
326
+ setError(null);
327
+ const connected = data.accounts.find(
328
+ (account) => !accountIdsBeforeRedirect.current.has(account.accountId),
329
+ );
330
+ if (connected) onConnected(connected.accountId);
331
+ });
332
+ });
333
+
306
334
  const startMutation = useMutation({
307
335
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
308
336
  onSuccess: (data) => {
309
337
  // A step the user left while this was in flight does not get to take the
310
338
  // window with it. `assign` is not synchronous either — the page is still
311
339
  // here while the browser fetches Microsoft's — so the control stays busy
312
- // until the window is actually looked at again.
340
+ // until this window has been away and come back.
313
341
  if (!stepIsMounted.current) return;
342
+ markRedirectStarted();
314
343
  setAwaitingReturn(true);
315
344
  window.location.assign(data.authorizationUrl);
316
345
  },
@@ -320,39 +349,6 @@ function StepMicrosoftEmail({
320
349
  },
321
350
  });
322
351
 
323
- // Microsoft's answer comes back to whichever window the platform picks, and
324
- // on iOS that is often the browser rather than the app launched from the
325
- // home screen. So this window decides on the account list rather than on
326
- // having been the one that got the redirect: an account that was not there
327
- // before carries the wizard forward.
328
- //
329
- // Every look at this window is a chance to find that account, not a verdict
330
- // on the sign-in — a user who switches back mid-flow to read a password has
331
- // not failed anything, so nothing here concludes and the check stays armed
332
- // until an account appears or the user leaves the step.
333
- useReturnFromRedirect(
334
- awaitingReturn,
335
- useCallback(() => {
336
- // Being looked at again is the proof the redirect is over, however it
337
- // ended: the window that was leaving is back, so the button is too.
338
- setPreparing(false);
339
- void refetchConfig().then(({ data, isError }) => {
340
- if (!stepIsMounted.current) return;
341
- if (isError || !data) {
342
- setError(
343
- "Couldn't check whether the sign-in finished. Open Settings › Accounts to see whether the account is connected.",
344
- );
345
- return;
346
- }
347
- setError(null);
348
- const connected = data.accounts.find(
349
- (account) => !accountIdsBeforeRedirect.current.has(account.accountId),
350
- );
351
- if (connected) onConnected(connected.accountId);
352
- });
353
- }, [refetchConfig, onConnected]),
354
- );
355
-
356
352
  // The account list is read first and the redirect goes from what it says, so
357
353
  // the window that comes back has something to recognise a new account
358
354
  // against. A list that cannot be read stops the flow here, where it can be
@@ -0,0 +1,75 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useReturnFromRedirect } from "./useReturnFromRedirect";
3
+
4
+ /**
5
+ * Run `onEnded` when the window that left for an external identity provider is
6
+ * being looked at again, and hand back the marker to call as it leaves.
7
+ *
8
+ * A control that starts a redirect stays busy until the redirect is over, and
9
+ * neither the mutation settling nor the next look at this window proves that:
10
+ * `window.location.assign` returns with the page still here while the browser
11
+ * fetches the provider's, and an app-switch away and back during that fetch is
12
+ * a look at a window that never left. Installed to a home screen there is no
13
+ * address bar and app-switching is how people move around, so that early look
14
+ * is the common case, not the corner one (#646). The page having actually been
15
+ * hidden is what separates the two.
16
+ *
17
+ * Every return leg, not just the first: a user who came back mid-sign-in to
18
+ * read a password has finished nothing, so the watch stays armed.
19
+ *
20
+ * The listeners are on from mount and `markRedirectStarted` runs synchronously
21
+ * on the line before `assign`, so nothing here depends on a render landing
22
+ * first.
23
+ *
24
+ * A page restored from the back-forward cache — browser Back out of the
25
+ * provider's consent screen — is a return leg on its own evidence. Without it a
26
+ * control that was left busy has nothing to clear it and stays busy for good.
27
+ */
28
+ export const useRedirectEnded = (onEnded: () => void): (() => void) => {
29
+ const armed = useRef(false);
30
+ const hidden = useRef(false);
31
+ const latest = useRef(onEnded);
32
+ latest.current = onEnded;
33
+
34
+ const settle = useCallback((): void => {
35
+ if (!armed.current || !hidden.current) return;
36
+ hidden.current = false;
37
+ latest.current();
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ const markHidden = () => {
42
+ hidden.current = true;
43
+ };
44
+ const handleVisibility = () => {
45
+ if (document.visibilityState !== "hidden") return;
46
+ markHidden();
47
+ };
48
+ const handlePageShow = (event: PageTransitionEvent) => {
49
+ if (!event.persisted) return;
50
+ // A restored page is the whole round trip in one event: the hide that
51
+ // preceded it belongs to the document this one resumes, and nothing
52
+ // else is going to report it.
53
+ markHidden();
54
+ settle();
55
+ };
56
+ document.addEventListener("visibilitychange", handleVisibility);
57
+ window.addEventListener("pagehide", markHidden);
58
+ window.addEventListener("pageshow", handlePageShow);
59
+ return () => {
60
+ document.removeEventListener("visibilitychange", handleVisibility);
61
+ window.removeEventListener("pagehide", markHidden);
62
+ window.removeEventListener("pageshow", handlePageShow);
63
+ };
64
+ }, [settle]);
65
+
66
+ // Always listening: the gate is the pair of refs above, which a redirect
67
+ // arms and a hidden interval satisfies, not whether this hook is mounted
68
+ // with a flag already flipped.
69
+ useReturnFromRedirect(true, settle);
70
+
71
+ return useCallback(() => {
72
+ armed.current = true;
73
+ hidden.current = false;
74
+ }, []);
75
+ };
@@ -1,7 +1,19 @@
1
1
  // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
2
  import assert from "node:assert";
3
- import { describe, test } from "node:test";
4
- import { mapOauthError } from "./accounts.tsx";
3
+ import { afterEach, describe, test } from "node:test";
4
+ import {
5
+ type AnyRoute,
6
+ type AnyRouter,
7
+ createMemoryHistory,
8
+ createRootRoute,
9
+ createRouter,
10
+ Outlet,
11
+ RouterProvider,
12
+ } from "@tanstack/react-router";
13
+ import { createElement } from "react";
14
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
15
+ import { type HttpMock, mockFetch } from "@/test-support/http";
16
+ import { mapOauthError, Route } from "./accounts.tsx";
5
17
 
6
18
  describe("mapOauthError", () => {
7
19
  test("access_denied returns cancellation message", () => {
@@ -63,3 +75,138 @@ describe("mapOauthError", () => {
63
75
  assert.ok(typeof result === "string" && result.length > 0);
64
76
  });
65
77
  });
78
+
79
+ /**
80
+ * The Reconnect button and the redirect it starts (#646, PR #955).
81
+ *
82
+ * The busy state rides a latch, not the mutation settling, because
83
+ * `window.location.assign` returns with the page still here. The latch needs an
84
+ * end as well as a start: Back out of Microsoft's consent screen restores this
85
+ * page with the account still asking to be re-authenticated, so nothing else
86
+ * clears it and the button reads "Redirecting…" for good.
87
+ */
88
+
89
+ /** Same-origin and hash-only, so jsdom performs it rather than logging it. */
90
+ const CONSENT_URL = "http://localhost/#microsoft-consent";
91
+
92
+ const REAUTH_ACCOUNT = {
93
+ accountId: "acc-1",
94
+ email: "matthijs@ischen.nl",
95
+ displayName: "Matthijs",
96
+ authType: "oauthMicrosoft",
97
+ connectionState: "reauth_required",
98
+ };
99
+
100
+ // The router reads `self` at construction; the shared jsdom globals stop at
101
+ // `window`.
102
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
103
+
104
+ let harness: DomHarness | undefined;
105
+ let http: HttpMock | undefined;
106
+
107
+ const settle = async (dom: DomHarness): Promise<void> => {
108
+ for (let round = 0; round < 4; round += 1) {
109
+ await dom.flush();
110
+ await dom.wait(20);
111
+ }
112
+ };
113
+
114
+ /**
115
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
116
+ * shared environment deliberately does not — so a spec that means "the window
117
+ * is being looked at" has to say so.
118
+ */
119
+ const setVisibility = (state: "hidden" | "visible"): void => {
120
+ Object.defineProperty(document, "visibilityState", {
121
+ configurable: true,
122
+ get: () => state,
123
+ });
124
+ };
125
+
126
+ /** jsdom has no `PageTransitionEvent`; `persisted` is what the hook reads. */
127
+ const pageShow = (persisted: boolean): Event => {
128
+ const event = new Event("pageshow");
129
+ Object.defineProperty(event, "persisted", { value: persisted });
130
+ return event;
131
+ };
132
+
133
+ /** The real route, mounted the way the generated tree mounts it. */
134
+ const mountAccounts = async (): Promise<DomHarness> => {
135
+ http = mockFetch((call) => {
136
+ if (call.path.endsWith("/oauth/microsoft/start")) {
137
+ return { authorizationUrl: CONSENT_URL };
138
+ }
139
+ if (call.path.endsWith("/config")) {
140
+ return { accounts: [REAUTH_ACCOUNT], mailboxes: [] };
141
+ }
142
+ return {};
143
+ });
144
+
145
+ const rootRoute = createRootRoute({ component: Outlet });
146
+ const accountsRoute = (
147
+ Route as unknown as { update: (options: unknown) => AnyRoute }
148
+ ).update({
149
+ id: "/settings/accounts",
150
+ path: "/settings/accounts",
151
+ getParentRoute: () => rootRoute,
152
+ });
153
+ const routeTree = rootRoute.addChildren([
154
+ accountsRoute,
155
+ ]) as unknown as AnyRoute;
156
+ const router = createRouter({
157
+ routeTree,
158
+ history: createMemoryHistory({ initialEntries: ["/settings/accounts"] }),
159
+ }) as unknown as AnyRouter;
160
+ await router.load();
161
+
162
+ const mounted = createDomHarness();
163
+ harness = mounted;
164
+ mounted.renderApp(createElement(RouterProvider, { router }));
165
+ await settle(mounted);
166
+ return mounted;
167
+ };
168
+
169
+ const startReconnect = async (dom: DomHarness): Promise<void> => {
170
+ dom.click(dom.byText("button", "Reconnect"));
171
+ await settle(dom);
172
+ };
173
+
174
+ describe("the Reconnect button and the redirect it starts", () => {
175
+ afterEach(() => {
176
+ harness?.close();
177
+ harness = undefined;
178
+ http?.restore();
179
+ http = undefined;
180
+ Reflect.deleteProperty(document, "visibilityState");
181
+ });
182
+
183
+ test("goes busy and stays busy while the redirect is in flight", async () => {
184
+ const dom = await mountAccounts();
185
+ await startReconnect(dom);
186
+
187
+ assert.match(dom.text(), /Redirecting…/);
188
+
189
+ // A look at a window that never left is not the redirect ending.
190
+ setVisibility("visible");
191
+ dom.dispatch(dom.document, new Event("visibilitychange"));
192
+ dom.dispatch(dom.window, pageShow(false));
193
+ await settle(dom);
194
+
195
+ assert.match(dom.text(), /Redirecting…/);
196
+ });
197
+
198
+ test("re-arms when the restored page still needs re-authenticating", async () => {
199
+ const dom = await mountAccounts();
200
+ await startReconnect(dom);
201
+
202
+ dom.dispatch(dom.window, pageShow(true));
203
+ await settle(dom);
204
+
205
+ assert.doesNotMatch(
206
+ dom.text(),
207
+ /Redirecting…/,
208
+ "Back out of the consent screen left the button busy for good",
209
+ );
210
+ assert.ok(dom.byText("button", "Reconnect"));
211
+ });
212
+ });
@@ -24,6 +24,7 @@ import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
24
24
  import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
25
25
  import { DangerZone } from "@/components/settings/DangerZone";
26
26
  import { ErrorState } from "@/components/ui/ErrorState";
27
+ import { useRedirectEnded } from "@/hooks/useRedirectEnded";
27
28
  import { useReturnFromRedirect } from "@/hooks/useReturnFromRedirect";
28
29
  import { formatRelativeTime } from "@/lib/format";
29
30
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
@@ -270,9 +271,20 @@ function AccountsSettings() {
270
271
  if (!account || !needsReauth(account)) setReconnectingAccountId(null);
271
272
  }, [config, reconnectingAccountId]);
272
273
 
274
+ // A redirect that ends without the reconnect landing — Back out of the
275
+ // consent screen, or an `assign` that never navigates — leaves an account
276
+ // that still asks to be re-authenticated, so the watch above has nothing to
277
+ // clear the latch on and the button would read "Redirecting…" for good. This
278
+ // window being back is the evidence the redirect is over, whatever it
279
+ // decided, and the control it left behind is live again.
280
+ const markReconnectStarted = useRedirectEnded(() => {
281
+ setReconnectingAccountId(null);
282
+ });
283
+
273
284
  const reconnectMutation = useMutation({
274
285
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
275
286
  onSuccess: (data) => {
287
+ markReconnectStarted();
276
288
  window.location.assign(data.authorizationUrl);
277
289
  },
278
290
  onError: (err) => {
@@ -393,9 +405,12 @@ function AccountsSettings() {
393
405
  {config.accounts.map((account) => {
394
406
  const isReauth = needsReauth(account);
395
407
  const isOAuthAccount = account.authType === "oauthMicrosoft";
396
- const isReconnecting =
397
- reconnectingAccountId === account.accountId &&
398
- reconnectMutation.isPending;
408
+ // The mutation settling does not mean the window has gone anywhere:
409
+ // `assign` leaves the page here while the browser fetches Microsoft's
410
+ // page. The busy state rides the latch instead, which only clears on
411
+ // evidence — the server saying the account no longer needs re-auth,
412
+ // or the start call failing.
413
+ const isReconnecting = reconnectingAccountId === account.accountId;
399
414
 
400
415
  const primaryAction: RowAction =
401
416
  isReauth && isOAuthAccount