@remit/web-client 0.0.241 → 0.0.243

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.241",
3
+ "version": "0.0.243",
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,166 @@
1
+ /**
2
+ * Coming back to a tab always leaves the poll loop with a next tick (#1031).
3
+ *
4
+ * A tick that fires while the tab is hidden deliberately does not reschedule —
5
+ * `visibilitychange` is the only thing that resumes the loop. While that resume
6
+ * was itself conditional on a poll being due, the resume could be skipped for
7
+ * good: press refresh (which opens the hot 30s window), hide the tab shortly
8
+ * after a hot tick, let the window lapse while away, and the return is measured
9
+ * against the ambient interval the tab never slept through. Nothing re-armed,
10
+ * and the tab stopped polling until the account list changed or the layout
11
+ * remounted.
12
+ *
13
+ * At the default the ambient and hot intervals are equal and the loop is safe
14
+ * either way, so these drive a deployment that lengthened
15
+ * `mailboxPollIntervalSeconds` — the only shape where the bug is reachable.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, beforeEach, describe, mock, test } from "node:test";
20
+ import { QueryClientProvider } from "@tanstack/react-query";
21
+ import { act, createElement } from "react";
22
+ import {
23
+ __resetHotSyncWindow,
24
+ HOT_POLL_INTERVAL_MS,
25
+ HOT_WINDOW_MS,
26
+ startHotSyncWindow,
27
+ } from "@/lib/hot-sync-window";
28
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
29
+ import { makeAccount } from "@/test-support/fixtures";
30
+ import { type HttpMock, mockFetch } from "@/test-support/http";
31
+
32
+ const ACCOUNT_ID = "acc-1";
33
+ const AMBIENT_SECONDS = 600;
34
+ const AMBIENT_MS = AMBIENT_SECONDS * 1000;
35
+ const T0 = 1_700_000_000_000;
36
+
37
+ // The hook resolves the ambient interval once, at module load, so the config has
38
+ // to be in place before it is imported.
39
+ globalThis.__REMIT_CONFIG__ = {
40
+ mailboxPollIntervalSeconds: String(AMBIENT_SECONDS),
41
+ };
42
+ const { __resetStaleAccountSyncGuard, POLL_INTERVAL_MS, useStaleAccountSync } =
43
+ await import("./useStaleAccountSync.js");
44
+
45
+ let harness: DomHarness | undefined;
46
+ let http: HttpMock | undefined;
47
+
48
+ function PollLoop() {
49
+ useStaleAccountSync([makeAccount({ accountId: ACCOUNT_ID, lastSyncAt: T0 })]);
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
55
+ * shared environment deliberately does not — so a spec about a tab being looked
56
+ * at has to say so.
57
+ */
58
+ const setVisibility = (state: "hidden" | "visible"): void => {
59
+ Object.defineProperty(document, "visibilityState", {
60
+ configurable: true,
61
+ get: () => state,
62
+ });
63
+ };
64
+
65
+ /** Enough microtask turns for a dispatched poll to reach the fetch seam. */
66
+ const settle = async (dom: DomHarness): Promise<void> => {
67
+ for (let round = 0; round < 3; round += 1) await dom.flush();
68
+ };
69
+
70
+ const mount = async (): Promise<DomHarness> => {
71
+ http = mockFetch(() => ({ status: "queued" }));
72
+ const dom = createDomHarness();
73
+ harness = dom;
74
+ dom.render(
75
+ createElement(
76
+ QueryClientProvider,
77
+ { client: dom.queryClient },
78
+ createElement(PollLoop),
79
+ ),
80
+ );
81
+ await settle(dom);
82
+ return dom;
83
+ };
84
+
85
+ const advance = async (dom: DomHarness, ms: number): Promise<void> => {
86
+ await act(async () => {
87
+ mock.timers.tick(ms);
88
+ });
89
+ await settle(dom);
90
+ };
91
+
92
+ const goVisible = async (dom: DomHarness): Promise<void> => {
93
+ setVisibility("visible");
94
+ dom.dispatch(dom.document, new Event("visibilitychange"));
95
+ await settle(dom);
96
+ };
97
+
98
+ const goHidden = async (dom: DomHarness): Promise<void> => {
99
+ setVisibility("hidden");
100
+ dom.dispatch(dom.document, new Event("visibilitychange"));
101
+ await settle(dom);
102
+ };
103
+
104
+ /** Every background sync this tab has fired since it mounted. */
105
+ const polls = (): number => http?.to(`/${ACCOUNT_ID}/sync`).length ?? 0;
106
+
107
+ describe("the poll loop across hide and show, on a long ambient interval", () => {
108
+ beforeEach(() => {
109
+ mock.timers.enable({ apis: ["setTimeout", "Date"], now: T0 });
110
+ });
111
+
112
+ afterEach(() => {
113
+ mock.timers.reset();
114
+ harness?.close();
115
+ harness = undefined;
116
+ http?.restore();
117
+ http = undefined;
118
+ __resetHotSyncWindow();
119
+ __resetStaleAccountSyncGuard();
120
+ Reflect.deleteProperty(document, "visibilityState");
121
+ });
122
+
123
+ test("the deployment under test really is on a lengthened interval", () => {
124
+ assert.equal(POLL_INTERVAL_MS, AMBIENT_MS);
125
+ });
126
+
127
+ test("keeps polling after a refresh press, a hidden hot window and a return", async () => {
128
+ setVisibility("visible");
129
+ const dom = await mount();
130
+
131
+ // The press: the hot window opens and the loop drops onto its cadence.
132
+ startHotSyncWindow();
133
+ await advance(dom, HOT_POLL_INTERVAL_MS);
134
+ assert.equal(polls(), 1, "the hot cadence polled once");
135
+
136
+ // Away before the next hot tick, and back only after the window lapsed.
137
+ await goHidden(dom);
138
+ await advance(dom, HOT_WINDOW_MS);
139
+ assert.equal(polls(), 1, "a hidden tab polled");
140
+
141
+ await goVisible(dom);
142
+ assert.equal(
143
+ polls(),
144
+ 1,
145
+ "the return polled a tab that is not due on the ambient interval",
146
+ );
147
+
148
+ await advance(dom, AMBIENT_MS);
149
+ assert.equal(polls(), 2, "the tab came back with no tick scheduled");
150
+ });
151
+
152
+ test("polls on return once the ambient interval has passed", async () => {
153
+ setVisibility("visible");
154
+ const dom = await mount();
155
+
156
+ await goHidden(dom);
157
+ await advance(dom, AMBIENT_MS);
158
+ assert.equal(polls(), 0, "a hidden tab polled");
159
+
160
+ await goVisible(dom);
161
+ assert.equal(polls(), 1, "a tab due a poll did not catch up on return");
162
+
163
+ await advance(dom, AMBIENT_MS);
164
+ assert.equal(polls(), 2, "the catch-up left no next tick behind it");
165
+ });
166
+ });
@@ -275,21 +275,28 @@ export const useStaleAccountSync = (
275
275
  return;
276
276
  }
277
277
  // Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
278
- // (with an immediate catch-up poll) once the tab is visible again.
279
- // A hot window running out while the tab is away costs nothing —
280
- // there is nobody watching a list to keep current.
278
+ // (with an immediate catch-up poll if one is due) once the tab is
279
+ // visible again. A hot window running out while the tab is away costs
280
+ // nothing — there is nobody watching a list to keep current.
281
281
  };
282
282
 
283
+ // A tick that fired while the tab was hidden left no timer behind, so
284
+ // coming back is the only thing that can re-arm the loop — it does so
285
+ // unconditionally. Due-ness decides whether this tab polls *now*, never
286
+ // whether a next tick exists: a tab that went to sleep on the hot cadence
287
+ // and came back after the window lapsed is not due against a longer
288
+ // ambient interval, and used to stop polling for good (#1031).
283
289
  const handleVisibilityChange = () => {
284
290
  if (document.visibilityState !== "visible") return;
285
- if (
286
- !hasPollIntervalElapsed(Date.now(), lastPollAt, currentPollIntervalMs())
287
- ) {
291
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
292
+ const now = Date.now();
293
+ const intervalMs = currentPollIntervalMs(now);
294
+ if (hasPollIntervalElapsed(now, lastPollAt, intervalMs)) {
295
+ poll();
296
+ timeoutId = setTimeout(tick, intervalMs);
288
297
  return;
289
298
  }
290
- if (timeoutId !== undefined) clearTimeout(timeoutId);
291
- poll();
292
- timeoutId = setTimeout(tick, currentPollIntervalMs());
299
+ timeoutId = setTimeout(tick, lastPollAt + intervalMs - now);
293
300
  };
294
301
 
295
302
  // A press opens the hot window, but this loop may already be asleep on a
@@ -108,7 +108,7 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
108
108
  [mailboxes, roleMap, translator, account.folderAppointments],
109
109
  );
110
110
 
111
- const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
111
+ const handleAppoint = (role: FolderRole, mailboxId: string) => {
112
112
  appointMutation.mutate({
113
113
  path: { accountId, role: NAV_ROLE_TO_CANONICAL[role] },
114
114
  body: { mailboxId },