@remit/web-client 0.0.209 → 0.0.211
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 +1 -1
- package/src/components/layout/Drawer.focus.test.ts +212 -0
- package/src/components/layout/Drawer.tsx +54 -4
- package/src/components/mail/intelligence-drawer.stories.tsx +123 -1
- package/src/hooks/use-system-update.test.ts +91 -8
- package/src/hooks/use-system-update.ts +87 -10
- package/src/lib/self-update-state.test.ts +57 -6
- package/src/lib/self-update-state.ts +71 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.211",
|
|
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": {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawer's focus trap (#747).
|
|
3
|
+
*
|
|
4
|
+
* The drawer is `aria-modal`, so a screen reader offers nothing outside it and
|
|
5
|
+
* the scrim puts everything outside it out of a pointer's reach. Tab has to
|
|
6
|
+
* agree with both: it cycles inside the drawer, and the ring is the controls a
|
|
7
|
+
* reader can actually see and press — not every element that happens to match
|
|
8
|
+
* a focusable selector.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
act,
|
|
14
|
+
createElement,
|
|
15
|
+
Fragment,
|
|
16
|
+
type ReactElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
20
|
+
import { Drawer } from "./Drawer";
|
|
21
|
+
|
|
22
|
+
let container: HTMLElement;
|
|
23
|
+
let root: Root;
|
|
24
|
+
let outside: HTMLButtonElement;
|
|
25
|
+
|
|
26
|
+
const press = (key: string, shiftKey = false): void => {
|
|
27
|
+
act(() => {
|
|
28
|
+
document.dispatchEvent(
|
|
29
|
+
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const named = (label: string): HTMLElement => {
|
|
35
|
+
const found = container.querySelector<HTMLElement>(`[data-name="${label}"]`);
|
|
36
|
+
if (!found) throw new Error(`expected an element named ${label}`);
|
|
37
|
+
return found;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const closeButton = (): HTMLElement => {
|
|
41
|
+
const buttons = container.querySelectorAll<HTMLElement>(
|
|
42
|
+
'.safe-area-frame [aria-label="Close menu"]',
|
|
43
|
+
);
|
|
44
|
+
const found = buttons[0];
|
|
45
|
+
if (!found) throw new Error("expected the drawer's close button");
|
|
46
|
+
return found;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const render = (isOpen: boolean, children: ReactNode): void => {
|
|
50
|
+
act(() => {
|
|
51
|
+
root.render(
|
|
52
|
+
createElement(Drawer, {
|
|
53
|
+
isOpen,
|
|
54
|
+
onClose: () => undefined,
|
|
55
|
+
ariaLabel: "Message details",
|
|
56
|
+
side: "right",
|
|
57
|
+
children,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const open = (children: ReactNode): void => render(true, children);
|
|
64
|
+
const close = (): void => render(false, null);
|
|
65
|
+
|
|
66
|
+
const button = (
|
|
67
|
+
name: string,
|
|
68
|
+
props: Record<string, unknown> = {},
|
|
69
|
+
): ReactElement =>
|
|
70
|
+
createElement(
|
|
71
|
+
"button",
|
|
72
|
+
{ key: name, type: "button", "data-name": name, ...props },
|
|
73
|
+
name,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
container = document.getElementById("root") as unknown as HTMLElement;
|
|
78
|
+
container.innerHTML = "";
|
|
79
|
+
outside = document.createElement("button");
|
|
80
|
+
outside.type = "button";
|
|
81
|
+
outside.textContent = "Toolbar toggle";
|
|
82
|
+
document.body.appendChild(outside);
|
|
83
|
+
root = createRoot(container);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
act(() => root.unmount());
|
|
88
|
+
outside.remove();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Drawer keeps Tab inside itself", () => {
|
|
92
|
+
it("wraps backwards from the first element to the last", () => {
|
|
93
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
94
|
+
closeButton().focus();
|
|
95
|
+
|
|
96
|
+
press("Tab", true);
|
|
97
|
+
|
|
98
|
+
assert.equal(document.activeElement, named("omega"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("wraps forwards from the last element to the first", () => {
|
|
102
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
103
|
+
named("omega").focus();
|
|
104
|
+
|
|
105
|
+
press("Tab");
|
|
106
|
+
|
|
107
|
+
assert.equal(document.activeElement, closeButton());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("pulls focus back in when it is somewhere else entirely", () => {
|
|
111
|
+
open(createElement(Fragment, null, button("alpha")));
|
|
112
|
+
outside.focus();
|
|
113
|
+
|
|
114
|
+
press("Tab");
|
|
115
|
+
|
|
116
|
+
assert.equal(document.activeElement, closeButton());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("leaves an interior Tab to the browser", () => {
|
|
120
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
121
|
+
named("alpha").focus();
|
|
122
|
+
|
|
123
|
+
press("Tab");
|
|
124
|
+
|
|
125
|
+
assert.equal(document.activeElement, named("alpha"));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("Drawer's ring is what a reader can reach", () => {
|
|
130
|
+
it("skips a scrim that took itself out of the tab order", () => {
|
|
131
|
+
open(
|
|
132
|
+
createElement(
|
|
133
|
+
Fragment,
|
|
134
|
+
null,
|
|
135
|
+
button("backdrop", { tabIndex: -1 }),
|
|
136
|
+
button("omega"),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
named("omega").focus();
|
|
140
|
+
|
|
141
|
+
press("Tab");
|
|
142
|
+
|
|
143
|
+
assert.equal(document.activeElement, closeButton());
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("skips disabled, aria-disabled and hidden controls", () => {
|
|
147
|
+
open(
|
|
148
|
+
createElement(
|
|
149
|
+
Fragment,
|
|
150
|
+
null,
|
|
151
|
+
button("omega"),
|
|
152
|
+
button("off", { disabled: true }),
|
|
153
|
+
button("stated-off", { "aria-disabled": "true" }),
|
|
154
|
+
button("gone", { style: { display: "none" } }),
|
|
155
|
+
button("inerted", { inert: true }),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
named("omega").focus();
|
|
159
|
+
|
|
160
|
+
press("Tab");
|
|
161
|
+
|
|
162
|
+
assert.equal(document.activeElement, closeButton());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The intelligence pane's reclassify dialog renders inside the drawer, over
|
|
167
|
+
* its panel. A ring scoped to the panel puts that dialog's Cancel last and
|
|
168
|
+
* keeps every control behind its backdrop in the cycle, so Tab walks onto
|
|
169
|
+
* buttons no pointer can reach — the very thing the trap exists to prevent.
|
|
170
|
+
*/
|
|
171
|
+
it("hands the ring to a modal opened inside it", () => {
|
|
172
|
+
open(
|
|
173
|
+
createElement(
|
|
174
|
+
Fragment,
|
|
175
|
+
null,
|
|
176
|
+
button("under-the-backdrop"),
|
|
177
|
+
createElement(
|
|
178
|
+
"div",
|
|
179
|
+
{
|
|
180
|
+
key: "nested",
|
|
181
|
+
role: "dialog",
|
|
182
|
+
"aria-modal": "true",
|
|
183
|
+
"aria-label": "Reclassify sender",
|
|
184
|
+
},
|
|
185
|
+
button("automated"),
|
|
186
|
+
button("Cancel"),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
named("Cancel").focus();
|
|
191
|
+
|
|
192
|
+
press("Tab");
|
|
193
|
+
|
|
194
|
+
assert.equal(document.activeElement, named("automated"));
|
|
195
|
+
|
|
196
|
+
press("Tab", true);
|
|
197
|
+
|
|
198
|
+
assert.equal(document.activeElement, named("Cancel"));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("Drawer gives focus back on the way out", () => {
|
|
203
|
+
it("returns it to whatever held it when the drawer went up", () => {
|
|
204
|
+
outside.focus();
|
|
205
|
+
open(createElement(Fragment, null, button("alpha")));
|
|
206
|
+
assert.equal(document.activeElement, closeButton());
|
|
207
|
+
|
|
208
|
+
close();
|
|
209
|
+
|
|
210
|
+
assert.equal(document.activeElement, outside);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -2,6 +2,37 @@ import { cn } from "@remit/ui";
|
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
4
4
|
|
|
5
|
+
const FOCUSABLE_SELECTOR =
|
|
6
|
+
"a[href], button, input, select, textarea, [tabindex]";
|
|
7
|
+
|
|
8
|
+
const isTabbable = (element: HTMLElement): boolean => {
|
|
9
|
+
if (element.tabIndex < 0) return false;
|
|
10
|
+
if (element.hasAttribute("disabled")) return false;
|
|
11
|
+
if (element.getAttribute("aria-disabled") === "true") return false;
|
|
12
|
+
if (element.closest("[inert], [hidden], [aria-hidden='true']")) return false;
|
|
13
|
+
const style = getComputedStyle(element);
|
|
14
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The elements Tab may reach, in the order it reaches them.
|
|
19
|
+
*
|
|
20
|
+
* A modal opened from inside the drawer takes the ring with it: the
|
|
21
|
+
* intelligence pane's reclassify dialog renders under the drawer's own panel,
|
|
22
|
+
* so a ring scoped to the panel puts its Cancel last and leaves every control
|
|
23
|
+
* behind that dialog's backdrop in the cycle — Tab walks onto buttons no
|
|
24
|
+
* pointer can reach, which is the bug the trap exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
const tabRing = (panel: HTMLElement): HTMLElement[] => {
|
|
27
|
+
const nested = panel.querySelectorAll<HTMLElement>(
|
|
28
|
+
'[role="dialog"][aria-modal="true"]',
|
|
29
|
+
);
|
|
30
|
+
const root = nested[nested.length - 1] ?? panel;
|
|
31
|
+
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
32
|
+
isTabbable,
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
5
36
|
interface DrawerProps {
|
|
6
37
|
isOpen: boolean;
|
|
7
38
|
onClose: () => void;
|
|
@@ -40,16 +71,35 @@ export const Drawer = ({
|
|
|
40
71
|
if (event.key === "Escape") {
|
|
41
72
|
event.preventDefault();
|
|
42
73
|
onClose();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Trap Tab inside the drawer: it is `aria-modal`, so a screen reader
|
|
77
|
+
// offers nothing outside it — and letting Tab walk out would land on
|
|
78
|
+
// controls a pointer cannot reach under the scrim (#747).
|
|
79
|
+
if (event.key !== "Tab" || !drawerRef.current) return;
|
|
80
|
+
const ring = tabRing(drawerRef.current);
|
|
81
|
+
if (ring.length === 0) return;
|
|
82
|
+
const first = ring[0];
|
|
83
|
+
const last = ring[ring.length - 1];
|
|
84
|
+
const active = document.activeElement;
|
|
85
|
+
const inside = active instanceof HTMLElement && ring.includes(active);
|
|
86
|
+
if (event.shiftKey) {
|
|
87
|
+
if (!inside || active === first) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
last.focus();
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!inside || active === last) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
first.focus();
|
|
43
96
|
}
|
|
44
97
|
};
|
|
45
98
|
|
|
46
99
|
document.addEventListener("keydown", handleKey);
|
|
47
100
|
|
|
48
101
|
// Move focus into the drawer
|
|
49
|
-
|
|
50
|
-
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
|
51
|
-
);
|
|
52
|
-
focusable?.focus();
|
|
102
|
+
if (drawerRef.current) tabRing(drawerRef.current)[0]?.focus();
|
|
53
103
|
|
|
54
104
|
// Lock body scroll
|
|
55
105
|
const previousOverflow = document.body.style.overflow;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Avatar,
|
|
3
|
+
type IntelligenceData,
|
|
4
|
+
IntelligencePanel,
|
|
5
|
+
RefreshButton,
|
|
6
|
+
ShellTopBar,
|
|
7
|
+
} from "@remit/ui";
|
|
2
8
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
9
|
import { ChevronLeft, Info } from "lucide-react";
|
|
4
10
|
import { useState } from "react";
|
|
5
11
|
import { expect, userEvent, within } from "storybook/test";
|
|
6
12
|
import { Drawer } from "@/components/layout/Drawer";
|
|
13
|
+
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
7
14
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
8
15
|
|
|
9
16
|
/**
|
|
@@ -29,6 +36,8 @@ type Story = StoryObj;
|
|
|
29
36
|
|
|
30
37
|
const PHONE_WIDTH = 390;
|
|
31
38
|
const PHONE_HEIGHT = 720;
|
|
39
|
+
const MID_WIDTH = 1100;
|
|
40
|
+
const MID_HEIGHT = 640;
|
|
32
41
|
|
|
33
42
|
const intelligence: IntelligenceData = {
|
|
34
43
|
sender: {
|
|
@@ -213,6 +222,119 @@ export const DismissedAndNotCarried: Story = {
|
|
|
213
222
|
},
|
|
214
223
|
};
|
|
215
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The two-pane desktop band between 1024 and 1280px, where the rail has no room
|
|
227
|
+
* and the drawer is the surface. The drawer is right-anchored and 320px wide,
|
|
228
|
+
* so it lands squarely on the reading toolbar's own intelligence toggle — the
|
|
229
|
+
* control that put it there. The shell's top bar is here because the geometry
|
|
230
|
+
* is the point: it is what stands the toolbar clear of the drawer's own header,
|
|
231
|
+
* as it does in the app.
|
|
232
|
+
*/
|
|
233
|
+
const MidWidthReader = () => {
|
|
234
|
+
const drawer = useIntelligenceDrawer(messages[0]?.id ?? null);
|
|
235
|
+
const [search, setSearch] = useState("");
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
className="flex flex-col overflow-hidden rounded-lg border border-line bg-canvas"
|
|
240
|
+
// As on the phone frame: the transform makes this a containing block,
|
|
241
|
+
// so the drawer's `position: fixed` resolves against it.
|
|
242
|
+
style={{
|
|
243
|
+
width: MID_WIDTH,
|
|
244
|
+
height: MID_HEIGHT,
|
|
245
|
+
transform: "translateZ(0)",
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<ShellTopBar
|
|
249
|
+
search={{
|
|
250
|
+
value: search,
|
|
251
|
+
scope: "global",
|
|
252
|
+
onChange: setSearch,
|
|
253
|
+
onClear: () => setSearch(""),
|
|
254
|
+
onClearQuery: () => setSearch(""),
|
|
255
|
+
onRemoveChip: () => undefined,
|
|
256
|
+
}}
|
|
257
|
+
onCompose={() => undefined}
|
|
258
|
+
onReportBug={() => undefined}
|
|
259
|
+
onOpenSettings={() => undefined}
|
|
260
|
+
refreshControl={
|
|
261
|
+
<RefreshButton
|
|
262
|
+
state="idle"
|
|
263
|
+
label="Refresh all accounts"
|
|
264
|
+
onRefresh={() => undefined}
|
|
265
|
+
/>
|
|
266
|
+
}
|
|
267
|
+
account={
|
|
268
|
+
<Avatar
|
|
269
|
+
name="Matthijs van Henten"
|
|
270
|
+
email="mvh@example.com"
|
|
271
|
+
size="sm"
|
|
272
|
+
/>
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
<MessageToolbar
|
|
276
|
+
hasThread
|
|
277
|
+
intelligenceOpen={drawer.isOpen}
|
|
278
|
+
canToggleIntelligence
|
|
279
|
+
onToggleIntelligence={drawer.toggle}
|
|
280
|
+
/>
|
|
281
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm text-fg">
|
|
282
|
+
<h1 className="text-lg font-semibold leading-snug">
|
|
283
|
+
{messages[0]?.subject}
|
|
284
|
+
</h1>
|
|
285
|
+
<p className="mt-1 text-2xs text-fg-subtle">{messages[0]?.from}</p>
|
|
286
|
+
<p className="mt-4 max-w-2xl">{messages[0]?.body}</p>
|
|
287
|
+
</div>
|
|
288
|
+
<Drawer
|
|
289
|
+
isOpen={drawer.isOpen}
|
|
290
|
+
onClose={drawer.close}
|
|
291
|
+
ariaLabel="Message details"
|
|
292
|
+
side="right"
|
|
293
|
+
>
|
|
294
|
+
<IntelligencePanel data={intelligence} hideCloseButton />
|
|
295
|
+
</Drawer>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The toggle the drawer covers. It is the only way into this surface for a
|
|
302
|
+
* message with no warning on it, so the drawer lands on it — and stays there:
|
|
303
|
+
* the toggle is the way in and nothing else. Getting back out is the drawer's
|
|
304
|
+
* own job, and its X sits clear of the toggle to do it (#747).
|
|
305
|
+
*/
|
|
306
|
+
export const CoveredToggle: Story = {
|
|
307
|
+
render: () => <MidWidthReader />,
|
|
308
|
+
play: async ({ canvasElement }) => {
|
|
309
|
+
const canvas = within(canvasElement);
|
|
310
|
+
|
|
311
|
+
await userEvent.click(
|
|
312
|
+
canvas.getByRole("button", { name: "Show intelligence sidebar" }),
|
|
313
|
+
);
|
|
314
|
+
const drawer = canvas.getByRole("dialog", { name: "Message details" });
|
|
315
|
+
await expect(drawer).toBeVisible();
|
|
316
|
+
|
|
317
|
+
// The toggle stays in the toolbar the drawer covers. Nothing lifts it over
|
|
318
|
+
// the surface it opened, so a press aimed at it reaches the drawer.
|
|
319
|
+
await expect(drawer).not.toContainElement(
|
|
320
|
+
canvas.getByRole("button", { name: "Hide intelligence sidebar" }),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// The header's X, not the scrim that shares its name: the second of the
|
|
324
|
+
// two, and the one a reader can see.
|
|
325
|
+
const close = within(drawer).getAllByLabelText("Close menu")[1];
|
|
326
|
+
await expect(close).toBeVisible();
|
|
327
|
+
await userEvent.click(close);
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
canvas.queryByRole("dialog", { name: "Message details" }),
|
|
331
|
+
).toBeNull();
|
|
332
|
+
await expect(
|
|
333
|
+
canvas.getByRole("button", { name: "Show intelligence sidebar" }),
|
|
334
|
+
).toHaveAttribute("aria-pressed", "false");
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
216
338
|
/** The drawer over the dark theme. */
|
|
217
339
|
export const OpenOverThreadDark: Story = {
|
|
218
340
|
name: "Open Over Thread (dark)",
|
|
@@ -16,13 +16,20 @@ import { act, createElement, type ReactNode } from "react";
|
|
|
16
16
|
import { SelfUpdateOverlay } from "../components/self-update/SelfUpdateOverlay";
|
|
17
17
|
import { AdvancedNavIcon } from "../components/settings/AdvancedNavIcon";
|
|
18
18
|
import { SelfUpdatePanel } from "../components/settings/SelfUpdatePanel";
|
|
19
|
+
import { __resetFatalError, getCurrentFatalError } from "../lib/fatal-error";
|
|
19
20
|
import {
|
|
20
21
|
APPLY_BUDGET_SECONDS,
|
|
21
22
|
NEVER_CAME_BACK_MARGIN_SECONDS,
|
|
22
23
|
} from "../lib/self-update-state";
|
|
23
24
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
24
|
-
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
25
25
|
import {
|
|
26
|
+
type HttpCall,
|
|
27
|
+
type HttpMock,
|
|
28
|
+
httpError,
|
|
29
|
+
mockFetch,
|
|
30
|
+
} from "../test-support/http";
|
|
31
|
+
import {
|
|
32
|
+
CHECK_ANSWER_BUDGET_MS,
|
|
26
33
|
type SelfUpdateApi,
|
|
27
34
|
SelfUpdateProvider,
|
|
28
35
|
useSelfUpdate,
|
|
@@ -36,9 +43,11 @@ let http: HttpMock | undefined;
|
|
|
36
43
|
beforeEach(() => {
|
|
37
44
|
globalThis.localStorage = globalThis.window.localStorage;
|
|
38
45
|
localStorage.clear();
|
|
46
|
+
__resetFatalError();
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
afterEach(() => {
|
|
50
|
+
__resetFatalError();
|
|
42
51
|
http?.restore();
|
|
43
52
|
http = undefined;
|
|
44
53
|
harness?.close();
|
|
@@ -105,12 +114,12 @@ async function settle(dom: DomHarness): Promise<void> {
|
|
|
105
114
|
* what the real seam returns the moment it accepts the request.
|
|
106
115
|
*/
|
|
107
116
|
function mountApi(
|
|
108
|
-
getResponse: () => unknown,
|
|
117
|
+
getResponse: (call: HttpCall) => unknown,
|
|
109
118
|
children: ReactNode = null,
|
|
110
119
|
): { dom: DomHarness; api: () => SelfUpdateApi } {
|
|
111
120
|
http = mockFetch((call) => {
|
|
112
121
|
if (call.path.endsWith("/system/update") && call.method === "GET") {
|
|
113
|
-
return getResponse();
|
|
122
|
+
return getResponse(call);
|
|
114
123
|
}
|
|
115
124
|
return {
|
|
116
125
|
currentVersion: "0.9.3",
|
|
@@ -148,6 +157,15 @@ async function startUpdate(
|
|
|
148
157
|
});
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
async function press(dom: DomHarness, api: () => SelfUpdateApi): Promise<void> {
|
|
161
|
+
await act(async () => {
|
|
162
|
+
api().onCheck();
|
|
163
|
+
await dom.flush();
|
|
164
|
+
await dom.wait(1);
|
|
165
|
+
await dom.flush();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
async function renderSurface(
|
|
152
170
|
getResponse: () => unknown,
|
|
153
171
|
children: ReactNode,
|
|
@@ -428,7 +446,23 @@ describe("useSystemUpdate — actions", () => {
|
|
|
428
446
|
assert.equal(after.status === "ready" && after.section.status, "upToDate");
|
|
429
447
|
});
|
|
430
448
|
|
|
431
|
-
test("
|
|
449
|
+
test("the press asks the updater for a check, not another read of the same file (#599)", async () => {
|
|
450
|
+
// The whole of #599: a plain re-read answers with the verdict it already
|
|
451
|
+
// had. Only refresh=true records a request the updater will act on.
|
|
452
|
+
const { dom, api } = mountApi(() => available);
|
|
453
|
+
await settle(dom);
|
|
454
|
+
const before = http?.calls.length ?? 0;
|
|
455
|
+
|
|
456
|
+
await press(dom, api);
|
|
457
|
+
|
|
458
|
+
const refreshes = (http?.calls ?? [])
|
|
459
|
+
.slice(before)
|
|
460
|
+
.filter((call) => call.url.includes("refresh=true"));
|
|
461
|
+
assert.equal(refreshes.length, 1);
|
|
462
|
+
assert.equal(refreshes[0].method, "GET");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("retrying the connection re-polls the surface", async () => {
|
|
432
466
|
let calls = 0;
|
|
433
467
|
const { dom, api } = mountApi(() => {
|
|
434
468
|
calls += 1;
|
|
@@ -437,10 +471,6 @@ describe("useSystemUpdate — actions", () => {
|
|
|
437
471
|
await settle(dom);
|
|
438
472
|
const afterMount = calls;
|
|
439
473
|
|
|
440
|
-
await act(async () => {
|
|
441
|
-
api().onCheck();
|
|
442
|
-
await dom.flush();
|
|
443
|
-
});
|
|
444
474
|
await act(async () => {
|
|
445
475
|
api().onRetryConnection();
|
|
446
476
|
await dom.flush();
|
|
@@ -448,4 +478,57 @@ describe("useSystemUpdate — actions", () => {
|
|
|
448
478
|
|
|
449
479
|
assert.equal(calls > afterMount, true);
|
|
450
480
|
});
|
|
481
|
+
|
|
482
|
+
test("a press the updater never answers ends loudly, not in a spinner (#599)", async () => {
|
|
483
|
+
// The poll keeps answering with the same bytes, so nothing the hook reads
|
|
484
|
+
// changes and no render would notice the wait running out on its own.
|
|
485
|
+
const { dom, api } = mountApi(
|
|
486
|
+
() => available,
|
|
487
|
+
createElement(SelfUpdatePanel),
|
|
488
|
+
);
|
|
489
|
+
await settle(dom);
|
|
490
|
+
|
|
491
|
+
const realNow = Date.now;
|
|
492
|
+
try {
|
|
493
|
+
await act(async () => {
|
|
494
|
+
api().onCheck();
|
|
495
|
+
// The press is stamped off the real clock; the wait it then arms is
|
|
496
|
+
// measured against one already past the budget, so the deadline the
|
|
497
|
+
// hook sets for itself lands on the next tick, not in half a minute.
|
|
498
|
+
Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
|
|
499
|
+
await dom.flush();
|
|
500
|
+
});
|
|
501
|
+
// The effects have run and the deadline is armed — let it fire.
|
|
502
|
+
await dom.wait(5);
|
|
503
|
+
await dom.flush();
|
|
504
|
+
|
|
505
|
+
assert.match(dom.html(), /The updater did not answer/);
|
|
506
|
+
assert.match(dom.html(), /remit logs updater/);
|
|
507
|
+
assert.doesNotMatch(dom.html(), /Looking for a newer version/);
|
|
508
|
+
} finally {
|
|
509
|
+
Date.now = realNow;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
|
|
514
|
+
let refused = false;
|
|
515
|
+
const { dom, api } = mountApi(
|
|
516
|
+
(call) =>
|
|
517
|
+
refused && call.url.includes("refresh=true")
|
|
518
|
+
? httpError(500)
|
|
519
|
+
: available,
|
|
520
|
+
createElement(SelfUpdatePanel),
|
|
521
|
+
);
|
|
522
|
+
await settle(dom);
|
|
523
|
+
|
|
524
|
+
refused = true;
|
|
525
|
+
await press(dom, api);
|
|
526
|
+
|
|
527
|
+
assert.match(dom.html(), /could not be requested/);
|
|
528
|
+
assert.match(dom.html(), /answered 500/);
|
|
529
|
+
assert.doesNotMatch(dom.html(), /Install 0\.9\.4/);
|
|
530
|
+
// The press is a raw SDK call, outside the cache the global sink watches:
|
|
531
|
+
// a 5xx escalates from here or from nowhere at all.
|
|
532
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
533
|
+
});
|
|
451
534
|
});
|
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
* nothing and takes the server's answer as it finds it.
|
|
13
13
|
*
|
|
14
14
|
* Polling follows the run: every 30 seconds while idle, every 5 seconds while a
|
|
15
|
-
* run is in flight
|
|
15
|
+
* run is in flight, this page is waiting on one it started, or it is waiting on
|
|
16
|
+
* a check it pressed for.
|
|
16
17
|
*/
|
|
17
18
|
import {
|
|
18
19
|
systemOperationsApplySystemUpdateMutation,
|
|
19
20
|
systemOperationsGetSystemUpdateOptions,
|
|
20
21
|
systemOperationsGetSystemUpdateQueryKey,
|
|
21
22
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
23
|
+
import { systemOperationsGetSystemUpdate } from "@remit/api-http-client/sdk.gen.ts";
|
|
22
24
|
import type {
|
|
23
25
|
RemitImapSystemUpdateResponse,
|
|
24
26
|
RemitImapSystemUpdateRun,
|
|
@@ -35,8 +37,13 @@ import {
|
|
|
35
37
|
useRef,
|
|
36
38
|
useState,
|
|
37
39
|
} from "react";
|
|
40
|
+
import { shouldEscalate, softErrorMeta } from "@/lib/error-classifier";
|
|
41
|
+
import { reportFatalError } from "@/lib/fatal-error";
|
|
38
42
|
import {
|
|
39
43
|
appliesSchemaMigration,
|
|
44
|
+
type CheckPress,
|
|
45
|
+
checkAnswered,
|
|
46
|
+
checkRequestFailureReason,
|
|
40
47
|
deriveUpdateSurface,
|
|
41
48
|
type HeldRun,
|
|
42
49
|
isSurfaceAbsent,
|
|
@@ -48,6 +55,19 @@ import {
|
|
|
48
55
|
const IDLE_POLL_MS = 30_000;
|
|
49
56
|
const RUN_POLL_MS = 5_000;
|
|
50
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How long a pressed check waits for the updater before the pane calls it a
|
|
60
|
+
* failure. The backend only records the request; the updater picks it up on a
|
|
61
|
+
* five-second watch tick, so half a minute is several ticks — patient enough for
|
|
62
|
+
* a busy box, short enough that a press against a dead updater is answered
|
|
63
|
+
* rather than left spinning for good.
|
|
64
|
+
*/
|
|
65
|
+
export const CHECK_ANSWER_BUDGET_MS = 30_000;
|
|
66
|
+
|
|
67
|
+
/** The press was recorded and nothing came back. Names the process and the log. */
|
|
68
|
+
export const UPDATER_SILENT_REASON =
|
|
69
|
+
"The updater did not answer. Run `remit logs updater` to see why.";
|
|
70
|
+
|
|
51
71
|
export interface SelfUpdateApi {
|
|
52
72
|
surface: UpdateSurface;
|
|
53
73
|
/** Whether the pending release runs a schema migration during the window. */
|
|
@@ -55,7 +75,7 @@ export interface SelfUpdateApi {
|
|
|
55
75
|
currentVersion: string | undefined;
|
|
56
76
|
/** The available release, for the consent dialog. */
|
|
57
77
|
release: ReleaseInfo | undefined;
|
|
58
|
-
/**
|
|
78
|
+
/** Ask the updater for a fresh check, showing a `checking` pane until it answers. */
|
|
59
79
|
onCheck: () => void;
|
|
60
80
|
/** Request a specific release — consent has been given. */
|
|
61
81
|
install: (targetVersion: string) => void;
|
|
@@ -69,10 +89,13 @@ function pollInterval(
|
|
|
69
89
|
error: unknown,
|
|
70
90
|
run: RemitImapSystemUpdateRun | null,
|
|
71
91
|
hasHeldRun: boolean,
|
|
92
|
+
hasPress: boolean,
|
|
72
93
|
): number | false {
|
|
73
94
|
if (isSurfaceAbsent(error) && !hasHeldRun) return false;
|
|
74
95
|
const inFlight = run !== null && run.outcome === null;
|
|
75
|
-
|
|
96
|
+
// A press is waiting on the updater's next watch tick (#599), so it polls at
|
|
97
|
+
// the run cadence — and only until the wait ends, which drops the press.
|
|
98
|
+
if (hasHeldRun || inFlight || hasPress) return RUN_POLL_MS;
|
|
76
99
|
return IDLE_POLL_MS;
|
|
77
100
|
}
|
|
78
101
|
|
|
@@ -80,10 +103,13 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
80
103
|
const queryClient = useQueryClient();
|
|
81
104
|
const [held, setHeld] = useState<HeldRun | null>(null);
|
|
82
105
|
const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
|
|
83
|
-
const [
|
|
106
|
+
const [checkPress, setCheckPress] = useState<CheckPress | null>(null);
|
|
107
|
+
const [checkFailure, setCheckFailure] = useState<string | null>(null);
|
|
84
108
|
|
|
85
109
|
const heldRef = useRef(held);
|
|
86
110
|
heldRef.current = held;
|
|
111
|
+
const pressRef = useRef(checkPress);
|
|
112
|
+
pressRef.current = checkPress;
|
|
87
113
|
|
|
88
114
|
const query = useQuery({
|
|
89
115
|
...systemOperationsGetSystemUpdateOptions(),
|
|
@@ -94,17 +120,21 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
94
120
|
query.state.error,
|
|
95
121
|
query.state.data?.run ?? null,
|
|
96
122
|
heldRef.current !== null,
|
|
123
|
+
pressRef.current !== null,
|
|
97
124
|
),
|
|
98
125
|
});
|
|
99
126
|
|
|
127
|
+
const dataRef = useRef(query.data);
|
|
128
|
+
dataRef.current = query.data;
|
|
129
|
+
|
|
100
130
|
const derived = deriveUpdateSurface({
|
|
101
131
|
data: query.data,
|
|
102
132
|
isError: query.isError,
|
|
103
133
|
error: query.error,
|
|
104
|
-
isFetching: query.isFetching,
|
|
105
134
|
held,
|
|
106
135
|
dismissedRunId,
|
|
107
|
-
|
|
136
|
+
checkPress,
|
|
137
|
+
checkFailure,
|
|
108
138
|
now: Date.now(),
|
|
109
139
|
});
|
|
110
140
|
|
|
@@ -120,15 +150,62 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
120
150
|
if (releaseHeld) setHeld((current) => (current === null ? current : null));
|
|
121
151
|
}, [releaseHeld]);
|
|
122
152
|
|
|
153
|
+
// The updater answered the press: let go of it, so the next poll renders the
|
|
154
|
+
// verdict rather than the spinner.
|
|
123
155
|
useEffect(() => {
|
|
124
|
-
if (
|
|
125
|
-
|
|
156
|
+
if (checkPress !== null && checkAnswered(checkPress, query.data))
|
|
157
|
+
setCheckPress(null);
|
|
158
|
+
}, [checkPress, query.data]);
|
|
159
|
+
|
|
160
|
+
// The wait has to end itself. A poll that answers with the same bytes changes
|
|
161
|
+
// nothing this hook reads, so nothing would re-render to notice the budget had
|
|
162
|
+
// run out, and the spinner would sit there for good (#599).
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (checkPress === null) return;
|
|
165
|
+
const remaining =
|
|
166
|
+
checkPress.pressedAt + CHECK_ANSWER_BUDGET_MS - Date.now();
|
|
167
|
+
const timer = setTimeout(
|
|
168
|
+
() => {
|
|
169
|
+
setCheckPress(null);
|
|
170
|
+
setCheckFailure(UPDATER_SILENT_REASON);
|
|
171
|
+
},
|
|
172
|
+
Math.max(0, remaining),
|
|
173
|
+
);
|
|
174
|
+
return () => clearTimeout(timer);
|
|
175
|
+
}, [checkPress]);
|
|
126
176
|
|
|
127
177
|
const { refetch } = query;
|
|
128
178
|
|
|
129
179
|
const onCheck = useCallback(() => {
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
// A plain refetch only re-reads state.json, so the answer would be exactly
|
|
181
|
+
// as old as it was before the press. refresh=true has the backend record a
|
|
182
|
+
// check request for the updater; the wait is this page's to keep, against
|
|
183
|
+
// the `lastCheckedAt` the server had when the control was pressed (#599).
|
|
184
|
+
setCheckFailure(null);
|
|
185
|
+
setCheckPress({
|
|
186
|
+
pressedAt: Date.now(),
|
|
187
|
+
since: dataRef.current?.check.lastCheckedAt,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
void systemOperationsGetSystemUpdate({
|
|
191
|
+
query: { refresh: true },
|
|
192
|
+
throwOnError: true,
|
|
193
|
+
})
|
|
194
|
+
.then(() => {
|
|
195
|
+
void refetch();
|
|
196
|
+
})
|
|
197
|
+
.catch((error: unknown) => {
|
|
198
|
+
// The request never reached the seam, so nothing is coming. Say so
|
|
199
|
+
// where the press was made instead of reverting to the old verdict.
|
|
200
|
+
// This is a raw SDK call, outside the query cache that feeds the
|
|
201
|
+
// global sink, so the 5xx the seam answers when the control volume
|
|
202
|
+
// is unwritable escalates from here or from nowhere.
|
|
203
|
+
if (shouldEscalate(error, softErrorMeta, "user")) {
|
|
204
|
+
reportFatalError(error);
|
|
205
|
+
}
|
|
206
|
+
setCheckPress(null);
|
|
207
|
+
setCheckFailure(checkRequestFailureReason(error));
|
|
208
|
+
});
|
|
132
209
|
}, [refetch]);
|
|
133
210
|
|
|
134
211
|
const onRetryConnection = useCallback(() => {
|
|
@@ -8,6 +8,7 @@ import { ApiError } from "./api";
|
|
|
8
8
|
import {
|
|
9
9
|
APPLY_BUDGET_SECONDS,
|
|
10
10
|
appliesSchemaMigration,
|
|
11
|
+
checkRequestFailureReason,
|
|
11
12
|
type DeriveInput,
|
|
12
13
|
deriveUpdateSurface,
|
|
13
14
|
type HeldRun,
|
|
@@ -67,10 +68,10 @@ function input(overrides: Partial<DeriveInput> = {}): DeriveInput {
|
|
|
67
68
|
data: undefined,
|
|
68
69
|
isError: false,
|
|
69
70
|
error: undefined,
|
|
70
|
-
isFetching: false,
|
|
71
71
|
held: null,
|
|
72
72
|
dismissedRunId: null,
|
|
73
|
-
|
|
73
|
+
checkPress: null,
|
|
74
|
+
checkFailure: null,
|
|
74
75
|
now: NOW,
|
|
75
76
|
...overrides,
|
|
76
77
|
};
|
|
@@ -229,12 +230,16 @@ describe("deriveUpdateSurface — the surface without a run", () => {
|
|
|
229
230
|
);
|
|
230
231
|
});
|
|
231
232
|
|
|
232
|
-
test("a
|
|
233
|
+
test("a press keeps checking while the server still reports the answer it had (#599)", () => {
|
|
234
|
+
const stale = {
|
|
235
|
+
status: "ok" as const,
|
|
236
|
+
updateAvailable: false,
|
|
237
|
+
lastCheckedAt: "2026-07-20T11:00:00.000Z",
|
|
238
|
+
};
|
|
233
239
|
const result = deriveUpdateSurface(
|
|
234
240
|
input({
|
|
235
|
-
data: response(),
|
|
236
|
-
|
|
237
|
-
isFetching: true,
|
|
241
|
+
data: response({ check: stale }),
|
|
242
|
+
checkPress: { pressedAt: NOW - 5_000, since: stale.lastCheckedAt },
|
|
238
243
|
}),
|
|
239
244
|
);
|
|
240
245
|
assert.equal(
|
|
@@ -242,6 +247,52 @@ describe("deriveUpdateSurface — the surface without a run", () => {
|
|
|
242
247
|
"checking",
|
|
243
248
|
);
|
|
244
249
|
});
|
|
250
|
+
|
|
251
|
+
test("a press settles the moment the server reports a newer lastCheckedAt (#599)", () => {
|
|
252
|
+
const result = deriveUpdateSurface(
|
|
253
|
+
input({
|
|
254
|
+
data: response({
|
|
255
|
+
check: {
|
|
256
|
+
status: "ok",
|
|
257
|
+
updateAvailable: false,
|
|
258
|
+
lastCheckedAt: "2026-07-20T11:59:50.000Z",
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
checkPress: {
|
|
262
|
+
pressedAt: NOW - 5_000,
|
|
263
|
+
since: "2026-07-20T11:00:00.000Z",
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
assert.equal(
|
|
268
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
269
|
+
"upToDate",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a failed press is reported verbatim, never swallowed into the old verdict (#599)", () => {
|
|
274
|
+
const result = deriveUpdateSurface(
|
|
275
|
+
input({
|
|
276
|
+
data: response({
|
|
277
|
+
check: {
|
|
278
|
+
status: "ok",
|
|
279
|
+
updateAvailable: true,
|
|
280
|
+
latestVersion: "0.9.4",
|
|
281
|
+
lastCheckedAt: "2026-07-20T11:00:00.000Z",
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
checkFailure: checkRequestFailureReason(new ApiError("boom", 500)),
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
assert.equal(result.surface.status, "ready");
|
|
288
|
+
if (result.surface.status !== "ready") return;
|
|
289
|
+
const section = result.surface.section;
|
|
290
|
+
assert.equal(section.status, "checkFailed");
|
|
291
|
+
if (section.status !== "checkFailed") return;
|
|
292
|
+
assert.match(section.reason, /answered 500/);
|
|
293
|
+
// The age of the answer it is still showing survives the failure.
|
|
294
|
+
assert.equal(section.lastCheckedAt, Date.parse("2026-07-20T11:00:00.000Z"));
|
|
295
|
+
});
|
|
245
296
|
});
|
|
246
297
|
|
|
247
298
|
describe("deriveUpdateSurface — check and run stay independent", () => {
|
|
@@ -57,6 +57,44 @@ export interface HeldRun {
|
|
|
57
57
|
startedAt: number;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The check this page asked for, held for as long as the page that asked lives.
|
|
62
|
+
* The server records the request and answers with the state it already had, so
|
|
63
|
+
* the wait is the client's to keep: `since` is `check.lastCheckedAt` as the
|
|
64
|
+
* server reported it at the moment of the press, and the answer has landed once
|
|
65
|
+
* the server reports a different one. Comparing against that stored value rather
|
|
66
|
+
* than against the press's own clock keeps the rule honest on a box whose clock
|
|
67
|
+
* differs from the browser's.
|
|
68
|
+
*/
|
|
69
|
+
export interface CheckPress {
|
|
70
|
+
/** Epoch millis when the control was pressed, for the bounded wait. */
|
|
71
|
+
pressedAt: number;
|
|
72
|
+
since: string | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function checkAnswered(
|
|
76
|
+
press: CheckPress,
|
|
77
|
+
data: RemitImapSystemUpdateResponse | undefined,
|
|
78
|
+
): boolean {
|
|
79
|
+
const lastCheckedAt = data?.check.lastCheckedAt;
|
|
80
|
+
return lastCheckedAt !== undefined && lastCheckedAt !== press.since;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Why the request the press fired never reached the seam. The status is named
|
|
85
|
+
* because a 500 here is a real fault on the box — the control volume unwritable
|
|
86
|
+
* is the one seen in the wild — and a press that quietly re-served the old
|
|
87
|
+
* verdict is exactly how #599 stayed invisible for an hour.
|
|
88
|
+
*/
|
|
89
|
+
export function checkRequestFailureReason(error: unknown): string {
|
|
90
|
+
const status = getErrorStatus(error);
|
|
91
|
+
const cause =
|
|
92
|
+
status === undefined
|
|
93
|
+
? "the server did not answer"
|
|
94
|
+
: `the server answered ${status}`;
|
|
95
|
+
return `The check could not be requested — ${cause}. Run \`remit logs backend\` to see why.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
export type UpdateOverlay =
|
|
61
99
|
| { kind: "none" }
|
|
62
100
|
| {
|
|
@@ -82,10 +120,12 @@ export interface DeriveInput {
|
|
|
82
120
|
data: RemitImapSystemUpdateResponse | undefined;
|
|
83
121
|
isError: boolean;
|
|
84
122
|
error: unknown;
|
|
85
|
-
isFetching: boolean;
|
|
86
123
|
held: HeldRun | null;
|
|
87
124
|
dismissedRunId: string | null;
|
|
88
|
-
|
|
125
|
+
/** The check this page pressed for, or null when it is not waiting on one. */
|
|
126
|
+
checkPress: CheckPress | null;
|
|
127
|
+
/** Why the request that press fired failed, or null when it did not. */
|
|
128
|
+
checkFailure: string | null;
|
|
89
129
|
now: number;
|
|
90
130
|
}
|
|
91
131
|
|
|
@@ -238,27 +278,49 @@ function terminalSection(
|
|
|
238
278
|
|
|
239
279
|
function checkSection(
|
|
240
280
|
data: RemitImapSystemUpdateResponse,
|
|
241
|
-
|
|
281
|
+
press: CheckPress | null,
|
|
282
|
+
failure: string | null,
|
|
242
283
|
now: number,
|
|
243
284
|
): SelfUpdateState {
|
|
244
285
|
const check = data.check;
|
|
245
|
-
|
|
286
|
+
const lastCheckedAt = parseIso(check.lastCheckedAt);
|
|
287
|
+
|
|
288
|
+
if (failure !== null) {
|
|
289
|
+
return {
|
|
290
|
+
status: "checkFailed",
|
|
291
|
+
version: data.currentVersion,
|
|
292
|
+
reason: failure,
|
|
293
|
+
lastCheckedAt,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The press is recorded and the updater has not answered it: the pane waits on
|
|
298
|
+
// a genuinely newer `lastCheckedAt` rather than re-serving the verdict it
|
|
299
|
+
// already had (#599). The caller drops the press when the wait runs out, which
|
|
300
|
+
// arrives here as a failure.
|
|
301
|
+
if (press !== null && !checkAnswered(press, data)) {
|
|
302
|
+
return { status: "checking", version: data.currentVersion };
|
|
303
|
+
}
|
|
246
304
|
|
|
247
305
|
if (check.status === "failed") {
|
|
248
306
|
return {
|
|
249
307
|
status: "checkFailed",
|
|
250
308
|
version: data.currentVersion,
|
|
251
309
|
reason: check.error ?? "Remit could not reach the update service.",
|
|
252
|
-
lastCheckedAt
|
|
310
|
+
lastCheckedAt,
|
|
253
311
|
};
|
|
254
312
|
}
|
|
255
313
|
|
|
256
314
|
// A configured surface that has not run its first check yet is never-checked,
|
|
257
315
|
// not checking: the updater runs the check on a cadence and has not written a
|
|
258
316
|
// result. A spinner here would run forever, since nothing on this poll is in
|
|
259
|
-
// flight. Only a
|
|
317
|
+
// flight. Only a press this page is still waiting on shows the spinner.
|
|
260
318
|
if (check.status === "disabled") {
|
|
261
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
status: "neverChecked",
|
|
321
|
+
version: data.currentVersion,
|
|
322
|
+
lastCheckedAt,
|
|
323
|
+
};
|
|
262
324
|
}
|
|
263
325
|
|
|
264
326
|
const release = releaseFromCheck(data, now);
|
|
@@ -412,7 +474,7 @@ function deriveHeld(
|
|
|
412
474
|
}
|
|
413
475
|
|
|
414
476
|
function displayFromData(input: DeriveInput): UpdateSurface {
|
|
415
|
-
const { data, isError,
|
|
477
|
+
const { data, isError, dismissedRunId, checkPress, checkFailure, now } =
|
|
416
478
|
input;
|
|
417
479
|
|
|
418
480
|
if (isError) {
|
|
@@ -464,7 +526,7 @@ function displayFromData(input: DeriveInput): UpdateSurface {
|
|
|
464
526
|
|
|
465
527
|
return {
|
|
466
528
|
status: "ready",
|
|
467
|
-
section: checkSection(data,
|
|
529
|
+
section: checkSection(data, checkPress, checkFailure, now),
|
|
468
530
|
overlay: { kind: "none" },
|
|
469
531
|
};
|
|
470
532
|
}
|