@remit/web-client 0.0.178 → 0.0.180

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.
@@ -2,9 +2,9 @@
2
2
  * Three contracts, each of which has already cost a regression.
3
3
  *
4
4
  * The list is read off a matched route id, never the parent /mail layout's
5
- * pathname: that pathname is "/mail" on every child route, and keying off it
6
- * routed every mailbox through the brief pane, so the message-row anchors
7
- * vanished.
5
+ * own pathname: that pathname is "/mail" on every child route, and keying off
6
+ * it routed every mailbox through the brief pane, so the message-row anchors
7
+ * vanished. The location's pathname is the whole address and says which list.
8
8
  *
9
9
  * The view key of a list equals the view key of anything nested under it. A
10
10
  * thread is a child route of the list it was opened from, so a key that moved
@@ -12,13 +12,15 @@
12
12
  * the view — and the query they had just typed would be re-seeded from the URL
13
13
  * and disappear the moment they opened a hit.
14
14
  *
15
- * And "am I the current list" is answered by the pathname, not by the matches,
15
+ * And "where is the reader" is answered by the pathname, not by the matches,
16
16
  * because the matches lag a navigation by as long as the destination takes to
17
- * mount.
17
+ * mount — both for "am I the current list" and for which view the search field
18
+ * is searching (#808).
18
19
  */
19
20
  import assert from "node:assert/strict";
20
21
  import { describe, it } from "node:test";
21
22
  import {
23
+ addressQuery,
22
24
  locationIsOnList,
23
25
  locationOpensDetail,
24
26
  MAIL_BRIEF_ROUTE_ID,
@@ -26,6 +28,7 @@ import {
26
28
  MAIL_MAILBOX_ROUTE_ID,
27
29
  MAIL_OUTBOX_ROUTE_ID,
28
30
  type MailRouteMatch,
31
+ mailboxViewKey,
29
32
  mailListRoute,
30
33
  mailViewKey,
31
34
  } from "./mail-route.js";
@@ -85,47 +88,57 @@ describe("mailListRoute", () => {
85
88
 
86
89
  describe("mailViewKey", () => {
87
90
  it("gives the four lists four distinct keys", () => {
88
- const keys = [brief, flagged, outbox, mailbox].map(mailViewKey);
91
+ const keys = [
92
+ "/mail/brief",
93
+ "/mail/flagged",
94
+ "/mail/outbox",
95
+ "/mail/inbox-1",
96
+ ].map(mailViewKey);
89
97
  assert.equal(new Set(keys).size, 4);
90
98
  });
91
99
 
92
100
  it("distinguishes two mailboxes", () => {
93
101
  assert.notEqual(
94
- mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" })),
95
- mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "archive-1" })),
102
+ mailViewKey("/mail/inbox-1"),
103
+ mailViewKey("/mail/archive-1"),
96
104
  );
97
105
  });
98
106
 
99
107
  it("is empty outside the mail shell", () => {
100
- assert.equal(mailViewKey([{ routeId: "__root__" }]), "");
108
+ assert.equal(mailViewKey("/onboarding"), "");
101
109
  });
102
110
 
103
- it("is empty on a mailbox route whose param has not resolved", () => {
104
- assert.equal(mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID])), "");
111
+ it("is empty on /mail itself, which names no list", () => {
112
+ assert.equal(mailViewKey("/mail"), "");
113
+ });
114
+
115
+ it("reads the list from the address the router has committed", () => {
116
+ // The matches lag a navigation by as long as the destination takes to
117
+ // mount, and the search field follows the address: text typed once this
118
+ // says the next mailbox is that mailbox's query, not the previous view's.
119
+ assert.equal(mailViewKey("/mail/inbox-1"), mailboxViewKey("inbox-1"));
120
+ assert.equal(mailViewKey("/mail/brief"), MAIL_BRIEF_ROUTE_ID);
121
+ assert.equal(mailViewKey("/mail/flagged"), MAIL_FLAGGED_ROUTE_ID);
122
+ assert.equal(mailViewKey("/mail/outbox"), MAIL_OUTBOX_ROUTE_ID);
105
123
  });
106
124
 
107
125
  // The trap: opening a thread must not read as leaving the list, or the
108
126
  // search field re-seeds and the typed query is gone.
109
127
  it("gives a list and its open thread the same key", () => {
110
- const lists: [readonly string[], Record<string, string> | undefined][] = [
111
- [[MAIL_BRIEF_ROUTE_ID], undefined],
112
- [[MAIL_FLAGGED_ROUTE_ID], undefined],
113
- [[MAIL_OUTBOX_ROUTE_ID], undefined],
114
- [[MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" }],
115
- ];
116
-
117
- for (const [routeIds, params] of lists) {
118
- const list = routeIds[0];
119
- const thread = `${list}/$threadId`;
120
- const message = `${thread}/$messageId`;
128
+ for (const list of [
129
+ "/mail/brief",
130
+ "/mail/flagged",
131
+ "/mail/outbox",
132
+ "/mail/inbox-1",
133
+ ]) {
121
134
  assert.equal(
122
- mailViewKey(matches([list, thread], params)),
123
- mailViewKey(matches([list], params)),
135
+ mailViewKey(`${list}/th-1`),
136
+ mailViewKey(list),
124
137
  `${list} changes view key when a thread opens`,
125
138
  );
126
139
  assert.equal(
127
- mailViewKey(matches([list, thread, message], params)),
128
- mailViewKey(matches([list], params)),
140
+ mailViewKey(`${list}/th-1/msg-1`),
141
+ mailViewKey(list),
129
142
  `${list} changes view key when a message expands`,
130
143
  );
131
144
  }
@@ -135,34 +148,23 @@ describe("mailViewKey", () => {
135
148
  // moved when it opened would wipe the query the reader was mid-search on.
136
149
  it("gives a list and its compose surface the same key", () => {
137
150
  assert.equal(
138
- mailViewKey(
139
- matches([
140
- MAIL_BRIEF_ROUTE_ID,
141
- `${MAIL_BRIEF_ROUTE_ID}/compose/{-$outboxMessageId}`,
142
- ]),
143
- ),
144
- mailViewKey(brief),
151
+ mailViewKey("/mail/brief/compose"),
152
+ mailViewKey("/mail/brief"),
145
153
  );
146
154
  assert.equal(
147
- mailViewKey(
148
- matches(
149
- [
150
- MAIL_MAILBOX_ROUTE_ID,
151
- `${MAIL_MAILBOX_ROUTE_ID}/compose/{-$outboxMessageId}`,
152
- ],
153
- {
154
- mailboxId: "inbox-1",
155
- },
156
- ),
157
- ),
158
- mailViewKey(mailbox),
155
+ mailViewKey("/mail/inbox-1/compose/draft-1"),
156
+ mailViewKey("/mail/inbox-1"),
159
157
  );
160
158
  });
161
159
 
162
- it("gives a list and its reading-pane index child the same key", () => {
160
+ it("ignores a query string and a fragment", () => {
163
161
  assert.equal(
164
- mailViewKey(matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/`])),
165
- mailViewKey(brief),
162
+ mailViewKey("/mail/brief?q=invoice"),
163
+ mailViewKey("/mail/brief"),
164
+ );
165
+ assert.equal(
166
+ mailViewKey("/mail/brief#intelligence"),
167
+ mailViewKey("/mail/brief"),
166
168
  );
167
169
  });
168
170
  });
@@ -238,3 +240,22 @@ describe("locationOpensDetail", () => {
238
240
  assert.equal(locationOpensDetail("/onboarding"), false);
239
241
  });
240
242
  });
243
+
244
+ describe("addressQuery", () => {
245
+ it("reads the query the address carries", () => {
246
+ assert.equal(addressQuery({ q: "invoice" }), "invoice");
247
+ });
248
+
249
+ it("is empty where the address carries none", () => {
250
+ assert.equal(addressQuery({}), "");
251
+ assert.equal(addressQuery({ wizard: "pick" }), "");
252
+ });
253
+
254
+ it("is empty for anything that is not a query string", () => {
255
+ // The location's search is parsed, not validated: a hand-edited `?q[]=`
256
+ // arrives as an array and must not reach the field as one.
257
+ assert.equal(addressQuery({ q: ["invoice"] }), "");
258
+ assert.equal(addressQuery(undefined), "");
259
+ assert.equal(addressQuery(null), "");
260
+ });
261
+ });
@@ -56,20 +56,42 @@ export function mailListRoute(
56
56
  * key of a list and of anything nested under it are equal; `lib/search-view.ts`
57
57
  * re-seeds the search field whenever this changes, and a key that moved when a
58
58
  * message opened would wipe the query the reader had just typed.
59
+ *
60
+ * Read off the location's pathname, for the reason `locationIsOnList` is: the
61
+ * router commits the address before it swaps the matches, and a route the
62
+ * reader has not visited yet has its component to fetch before it can. The
63
+ * search field follows the address, so text typed once the address says the
64
+ * next mailbox belongs to that mailbox — keying this off the matches instead
65
+ * made those keystrokes read as the previous view's leftovers and dropped them.
66
+ *
67
+ * The whole address, never the /mail match's own pathname, which is "/mail" on
68
+ * every child route.
59
69
  */
60
- export function mailViewKey(matches: readonly MailRouteMatch[]): string {
61
- const route = mailListRoute(matches);
62
- if (!route) return "";
63
- switch (route.list) {
64
- case "mailbox":
65
- return route.mailboxId ? mailboxViewKey(route.mailboxId) : "";
66
- case "flagged":
67
- return MAIL_FLAGGED_ROUTE_ID;
68
- case "outbox":
69
- return MAIL_OUTBOX_ROUTE_ID;
70
- case "brief":
71
- return MAIL_BRIEF_ROUTE_ID;
72
- }
70
+ export function mailViewKey(pathname: string): string {
71
+ const segments = pathname.split(/[?#]/)[0].split("/").filter(Boolean);
72
+ if (segments[0] !== "mail") return "";
73
+ const list = segments[1];
74
+ if (!list) return "";
75
+ if (list === "brief") return MAIL_BRIEF_ROUTE_ID;
76
+ if (list === "flagged") return MAIL_FLAGGED_ROUTE_ID;
77
+ if (list === "outbox") return MAIL_OUTBOX_ROUTE_ID;
78
+ return mailboxViewKey(list);
79
+ }
80
+
81
+ /**
82
+ * What the address says the query is.
83
+ *
84
+ * Read off the location's own search, never off the matched route's, for the
85
+ * reason `mailViewKey` reads the location's pathname: the router commits the
86
+ * whole address at once and swaps the matches afterwards. Taking the view from
87
+ * one and the query from the other splits a single move in two — the field sees
88
+ * the mailbox it is going to next to the query of the one it is leaving, and
89
+ * re-seeds itself with a query the reader has already navigated away from (#47).
90
+ */
91
+ export function addressQuery(search: unknown): string {
92
+ if (typeof search !== "object" || search === null) return "";
93
+ const { q } = search as { q?: unknown };
94
+ return typeof q === "string" ? q : "";
73
95
  }
74
96
 
75
97
  /** One mailbox's view key. Two mailboxes are two views. */
@@ -6,23 +6,13 @@
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { describe, it } from "node:test";
9
- import {
10
- MAIL_BRIEF_ROUTE_ID,
11
- type MailRouteMatch,
12
- mailViewKey,
13
- } from "./mail-route.js";
9
+ import { MAIL_BRIEF_ROUTE_ID, mailViewKey } from "./mail-route.js";
14
10
  import {
15
11
  committedSearchQuery,
16
12
  searchInputForView,
17
13
  shouldMirrorQuery,
18
14
  } from "./search-view.js";
19
15
 
20
- const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
21
- { routeId: "__root__" },
22
- { routeId: "/mail" },
23
- { routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
24
- ];
25
-
26
16
  describe("searchInputForView", () => {
27
17
  it("clears the field when the destination carries no query", () => {
28
18
  assert.equal(
@@ -173,7 +163,10 @@ describe("shouldMirrorQuery", () => {
173
163
  * both have to end it without ever writing over what the user is typing.
174
164
  */
175
165
  interface Shell {
166
+ /** The view the address names, which is what a render reads. */
176
167
  viewKey: string;
168
+ /** The view the text in the field was typed in (`hooks/useSearchField.ts`). */
169
+ typedInView: string;
177
170
  /** The path of the list whose mirror is running. */
178
171
  listPath: string;
179
172
  field: string;
@@ -189,10 +182,8 @@ const render = (
189
182
  listPath = shell.listPath,
190
183
  ): Shell => {
191
184
  const field =
192
- viewKey === shell.viewKey
193
- ? shell.field
194
- : (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
195
- return { ...shell, viewKey, listPath, field, url };
185
+ searchInputForView(shell.typedInView, viewKey, url) ?? shell.field;
186
+ return { ...shell, viewKey, typedInView: viewKey, listPath, field, url };
196
187
  };
197
188
 
198
189
  /**
@@ -213,15 +204,22 @@ const mirror = (shell: Shell, pathname = shell.listPath): Shell => {
213
204
  return { ...shell, url: committed };
214
205
  };
215
206
 
216
- const typing = (shell: Shell, text: string): Shell => ({
207
+ /**
208
+ * A keystroke, stamped with the view the address named when it landed. That is
209
+ * the address the router has already committed, which for one render is ahead
210
+ * of the view the shell is showing.
211
+ */
212
+ const typing = (shell: Shell, text: string, atView = shell.viewKey): Shell => ({
217
213
  ...shell,
218
214
  field: text,
215
+ typedInView: atView,
219
216
  });
220
217
  const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
221
218
 
222
219
  describe("search across a view change", () => {
223
220
  const searching: Shell = {
224
- viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
221
+ viewKey: mailViewKey("/mail/inbox-1"),
222
+ typedInView: mailViewKey("/mail/inbox-1"),
225
223
  listPath: "/mail/inbox-1",
226
224
  field: "invoice",
227
225
  debounced: "invoice",
@@ -231,12 +229,7 @@ describe("search across a view change", () => {
231
229
  it("ends the search when the user leaves the view", () => {
232
230
  // The nav link drops `q`, so the destination carries none.
233
231
  const next = mirror(
234
- render(
235
- searching,
236
- mailViewKey(matches("/mail/$mailboxId", "sent-1")),
237
- "",
238
- "/mail/sent-1",
239
- ),
232
+ render(searching, mailViewKey("/mail/sent-1"), "", "/mail/sent-1"),
240
233
  );
241
234
  assert.equal(next.field, "");
242
235
  assert.equal(next.url, "");
@@ -248,7 +241,8 @@ describe("search across a view change", () => {
248
241
  // the reader had just pushed, so Inbox never arrives.
249
242
  it("does not navigate back to the list the reader is leaving", () => {
250
243
  const brief: Shell = {
251
- viewKey: mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
244
+ viewKey: mailViewKey(MAIL_BRIEF_ROUTE_ID),
245
+ typedInView: mailViewKey(MAIL_BRIEF_ROUTE_ID),
252
246
  listPath: MAIL_BRIEF_ROUTE_ID,
253
247
  field: "inv",
254
248
  debounced: "",
@@ -266,7 +260,7 @@ describe("search across a view change", () => {
266
260
  // mirror must not write it back — that is #47 returning by another route.
267
261
  const landed = render(
268
262
  searching,
269
- mailViewKey(matches("/mail/$mailboxId", "sent-1")),
263
+ mailViewKey("/mail/sent-1"),
270
264
  "",
271
265
  "/mail/sent-1",
272
266
  );
@@ -280,7 +274,7 @@ describe("search across a view change", () => {
280
274
  const next = mirror(
281
275
  render(
282
276
  searching,
283
- mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
277
+ mailViewKey(MAIL_BRIEF_ROUTE_ID),
284
278
  "invoice",
285
279
  MAIL_BRIEF_ROUTE_ID,
286
280
  ),
@@ -289,10 +283,27 @@ describe("search across a view change", () => {
289
283
  assert.equal(next.url, "invoice");
290
284
  });
291
285
 
286
+ // #808: `waitForURL` returns on the address, so a reader — and a test — can
287
+ // type before the destination is on screen. That keystroke and the view
288
+ // change arrive in one render, and re-seeding it away left the field empty,
289
+ // the mirror with nothing to write, and no later render any reason to
290
+ // reconsider: the query never reached the URL at all.
291
+ it("keeps a query typed once the address already named the destination", () => {
292
+ const sent = mailViewKey("/mail/sent-1");
293
+ const typedOnArrival = typing(
294
+ { ...searching, field: "", debounced: "", url: "" },
295
+ "invoice",
296
+ sent,
297
+ );
298
+ const landed = settle(render(typedOnArrival, sent, "", "/mail/sent-1"));
299
+ assert.equal(landed.field, "invoice");
300
+ assert.equal(mirror(landed).url, "invoice");
301
+ });
302
+
292
303
  it("never clobbers characters the user is still typing", () => {
293
304
  // Opening a result and the q-mirror both re-render the same view. Neither
294
305
  // is a view change, so neither may reach into the field.
295
- const mailbox = mailViewKey(matches("/mail/$mailboxId", "inbox-1"));
306
+ const mailbox = mailViewKey("/mail/inbox-1");
296
307
  let shell = typing(
297
308
  { ...searching, field: "", debounced: "", url: "" },
298
309
  "i",
@@ -9,19 +9,29 @@
9
9
  * URL win on every view change: the field re-seeds from the location it lands
10
10
  * on, so switching mailbox clears a stale query while a deep link or a saved
11
11
  * search that carries `q` still arrives with the query intact.
12
+ *
13
+ * "Every view change" is the move the reader made, not every render that
14
+ * notices one. Text typed once the address already names the destination was
15
+ * typed in the destination, so it is that view's query and survives
16
+ * (`hooks/useSearchField.ts`).
12
17
  */
13
18
  import { locationIsOnList } from "./mail-route";
14
19
 
15
20
  /**
16
21
  * The field text after a view transition, or `undefined` when nothing changes
17
22
  * (same view — typing, opening a result, mirroring `q` back to the URL).
23
+ *
24
+ * `typedInView` is the view the text in the field was written in, not the view
25
+ * of the previous render. The two differ for exactly one render after the
26
+ * address moves, which is where a keystroke that followed the move lands
27
+ * (#808); calling it the previous render's view re-seeded that keystroke away.
18
28
  */
19
29
  export function searchInputForView(
20
- previousViewKey: string,
30
+ typedInView: string,
21
31
  viewKey: string,
22
32
  urlQuery: string,
23
33
  ): string | undefined {
24
- if (previousViewKey === viewKey) return undefined;
34
+ if (typedInView === viewKey) return undefined;
25
35
  return urlQuery;
26
36
  }
27
37
 
@@ -64,6 +74,15 @@ export interface MirrorDecision {
64
74
  * address is already the new one. A write from the outgoing list in that window
65
75
  * navigates back to itself, superseding the load in flight and replacing the
66
76
  * entry the reader just pushed — they click Inbox and land on the brief.
77
+ *
78
+ * The mirror asks this again whenever the address moves, not only when the
79
+ * field does (#808), so a settled query the URL has drifted away from is
80
+ * written again. That cannot loop: the answer is false the moment the URL says
81
+ * the committed query, which is what every write makes it say. Nor does it let
82
+ * the mirror fight a query arriving by URL — that arrives mid-debounce, where
83
+ * the first rule already refuses, and by the time the debounce settles the
84
+ * field has been re-seeded from the address it landed on
85
+ * (`hooks/useSearchField.ts`).
67
86
  */
68
87
  export function shouldMirrorQuery({
69
88
  searchInput,
@@ -9,7 +9,6 @@ import {
9
9
  Outlet,
10
10
  useNavigate,
11
11
  useRouterState,
12
- useSearch,
13
12
  } from "@tanstack/react-router";
14
13
  import { useCallback, useEffect, useMemo, useState } from "react";
15
14
  import { z } from "zod";
@@ -19,11 +18,11 @@ import { MailTopBar } from "@/components/layout/MailTopBar";
19
18
  import { MailNav } from "@/components/mail/MailNav";
20
19
  import { ErrorState } from "@/components/ui/ErrorState";
21
20
  import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
22
- import { useDebouncedValue } from "@/hooks/useDebouncedValue";
23
21
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
24
22
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
25
23
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
26
24
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
25
+ import { useSearchField } from "@/hooks/useSearchField";
27
26
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
28
27
  import {
29
28
  readIntelligencePref,
@@ -32,9 +31,8 @@ import {
32
31
  } from "@/lib/intelligence-pref";
33
32
  import { MailContext } from "@/lib/mail-context";
34
33
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
35
- import { mailListRoute, mailViewKey } from "@/lib/mail-route";
34
+ import { mailListRoute } from "@/lib/mail-route";
36
35
  import { buildAccountNameIndex } from "@/lib/search-token-index";
37
- import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
38
36
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
39
37
  import {
40
38
  isOverlayPanel,
@@ -80,7 +78,6 @@ export const Route = createFileRoute("/mail")({
80
78
  });
81
79
 
82
80
  function MailLayout() {
83
- const { q: searchQuery = "" } = useSearch({ from: "/mail" });
84
81
  const navigate = useNavigate();
85
82
  const tier = useLayoutTier();
86
83
  // Below the reading boundary (phone AND tablet) the shell shows a SINGLE
@@ -141,38 +138,11 @@ function MailLayout() {
141
138
  [intelligenceOpen, showPanels],
142
139
  );
143
140
 
144
- // Within one view, URL `q` seeds the input and is a one-directional write
145
- // target: the debounced local value drives the search API and is mirrored
146
- // back by the list route's own `useSearchMirror`. Across views the URL wins
147
- // again see the view-change adjustment below and `lib/search-view.ts` (#47).
148
- const [searchInput, setSearchInput] = useState(searchQuery);
149
- const debouncedSearchInput = useDebouncedValue(searchInput, 200);
150
- const committedQuery = committedSearchQuery(
151
- searchInput,
152
- debouncedSearchInput,
153
- );
154
-
155
- // Search is a mode of the view it was typed in, so leaving that view re-seeds
156
- // the field from wherever we land (#47): empty when the sidebar dropped `q`
157
- // (a folder switch starts that folder's search fresh), and the carried query
158
- // when the top bar's scope chip was removed and sent the user to the brief to
159
- // search everything. Views that differ only in what is open below the list —
160
- // a thread, a mirrored `q` — are the same view, so in-flight typing survives
161
- // them (`searchInputForView`, `mailViewKey`).
162
- //
163
- // Adjusted during render, not in an effect. This is React's documented
164
- // "adjusting state when a prop changes" pattern: both updates are to this
165
- // component's own state and are guarded by a changed value, so React re-runs
166
- // the render before committing and nothing is painted with the stale query.
167
- // An effect would commit one frame carrying the previous view's text, which
168
- // the mirror then has to be defended against.
169
- const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
170
- const [searchViewKey, setSearchViewKey] = useState(viewKey);
171
- if (searchViewKey !== viewKey) {
172
- const seeded = searchInputForView(searchViewKey, viewKey, searchQuery);
173
- setSearchViewKey(viewKey);
174
- if (seeded !== undefined) setSearchInput(seeded);
175
- }
141
+ // The one search field and the query it commits (`useSearchField`): seeded
142
+ // from the URL, mirrored back by each list route's own `useSearchMirror`,
143
+ // and re-seeded from the address whenever the reader leaves the view (#47).
144
+ const { searchInput, committedQuery, viewKey, setSearchInput } =
145
+ useSearchField();
176
146
 
177
147
  const {
178
148
  data: config,
@@ -229,20 +199,16 @@ function MailLayout() {
229
199
  handlers: composeHandlers,
230
200
  });
231
201
 
232
- const handleSearchChange = useCallback((query: string) => {
233
- setSearchInput(query);
234
- }, []);
235
-
236
202
  // Clears the search field; the list route's mirror drops `q` from the URL
237
203
  // after the debounce settles.
238
204
  const handleSearchClear = useCallback(() => {
239
205
  setSearchInput("");
240
- }, []);
206
+ }, [setSearchInput]);
241
207
 
242
208
  // Esc inside the search field clears only the query (#489).
243
209
  const handleSearchClearQuery = useCallback(() => {
244
210
  setSearchInput("");
245
- }, []);
211
+ }, [setSearchInput]);
246
212
 
247
213
  const handleToggleIntelligence = useCallback(() => {
248
214
  handleSetIntelligenceOpen(!intelligenceOpen);
@@ -283,7 +249,7 @@ function MailLayout() {
283
249
  searchQuery: committedQuery,
284
250
  searchInput,
285
251
  searchViewKey: viewKey,
286
- onSearchChange: handleSearchChange,
252
+ onSearchChange: setSearchInput,
287
253
  onSearchClear: handleSearchClear,
288
254
  onSearchClearQuery: handleSearchClearQuery,
289
255
  intelligenceOpen,