@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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The mirror corrects an address that has drifted from the settled query
3
+ * (#808).
4
+ *
5
+ * Typing lands more than one navigation: the debounce settles mid-word, the
6
+ * mirror writes that prefix, and the word is finished while that write is still
7
+ * in flight. The two can commit out of order, and an e2e run caught the result
8
+ * — the field holding `invoice`, the address holding `q=invo`, and neither
9
+ * moving again for the remaining two minutes of the test. The mirror compared
10
+ * against the URL without re-running on it, so a disagreement it had not caused
11
+ * was permanent.
12
+ *
13
+ * Driven through a real router, because the state under test is one only the
14
+ * router can produce: the address is moved out from under a mirror that has
15
+ * already settled, with the field and the committed query left where they were.
16
+ * The re-render is provoked rather than awaited — this harness does not
17
+ * propagate a router store change into React on its own, where the running app
18
+ * renders on every `q` — so what each case actually asks is whether the mirror
19
+ * reconsiders once it renders again.
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { afterEach, describe, it } from "node:test";
24
+ import {
25
+ type AnyRouter,
26
+ createMemoryHistory,
27
+ createRootRoute,
28
+ createRoute,
29
+ createRouter,
30
+ Outlet,
31
+ RouterProvider,
32
+ } from "@tanstack/react-router";
33
+ import { createElement, useState } from "react";
34
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
35
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
36
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
37
+ import { useSearchMirror } from "./useSearchMirror";
38
+
39
+ let harness: DomHarness | undefined;
40
+
41
+ afterEach(() => {
42
+ harness?.close();
43
+ harness = undefined;
44
+ });
45
+
46
+ // The router reads `self` at construction; the shared jsdom globals stop at
47
+ // `window`.
48
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
49
+
50
+ const LIST_PATH = "/mail/brief";
51
+ const RENDER_AGAIN = "render again";
52
+
53
+ const mailContext = (input: string, committed: string): MailContextValue => ({
54
+ accounts: [],
55
+ mailboxNameIndex: new Map(),
56
+ accountNameIndex: new Map(),
57
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
58
+ searchQuery: committed,
59
+ searchInput: input,
60
+ searchViewKey: LIST_PATH,
61
+ onSearchChange: () => {},
62
+ onSearchClear: () => {},
63
+ onSearchClearQuery: () => {},
64
+ intelligenceOpen: false,
65
+ onToggleIntelligence: () => {},
66
+ });
67
+
68
+ /**
69
+ * The brief on its own. Where a correcting write lands, and what it may close
70
+ * under it, is `search-mirror-detail.render.test.ts`'s question; this file asks
71
+ * only whether the address ends up saying the settled query.
72
+ */
73
+ const buildRouter = (href: string): AnyRouter => {
74
+ const passthrough = (search: Record<string, unknown>) => search;
75
+ const rootRoute = createRootRoute({ component: Outlet });
76
+ const mailRoute = createRoute({
77
+ getParentRoute: () => rootRoute,
78
+ path: "/mail",
79
+ validateSearch: passthrough,
80
+ component: Outlet,
81
+ });
82
+ const briefRoute = createRoute({
83
+ getParentRoute: () => mailRoute,
84
+ path: "/brief",
85
+ validateSearch: passthrough,
86
+ component: function BriefLayout() {
87
+ const [renders, setRenders] = useState(0);
88
+ useSearchMirror({ to: LIST_PATH });
89
+ return createElement(
90
+ "div",
91
+ null,
92
+ createElement(
93
+ "button",
94
+ {
95
+ type: "button",
96
+ "aria-label": RENDER_AGAIN,
97
+ onClick: () => setRenders(renders + 1),
98
+ },
99
+ String(renders),
100
+ ),
101
+ createElement(Outlet),
102
+ );
103
+ },
104
+ });
105
+ const routeTree = rootRoute.addChildren([
106
+ mailRoute.addChildren([briefRoute]),
107
+ ]);
108
+ return createRouter({
109
+ routeTree,
110
+ history: createMemoryHistory({ initialEntries: [href] }),
111
+ }) as unknown as AnyRouter;
112
+ };
113
+
114
+ const mount = async (
115
+ router: AnyRouter,
116
+ input: string,
117
+ committed: string,
118
+ ): Promise<DomHarness> => {
119
+ const created = createDomHarness();
120
+ harness = created;
121
+ await router.load();
122
+ created.renderApp(
123
+ createElement(
124
+ MailContext.Provider,
125
+ { value: mailContext(input, committed) },
126
+ createElement(RouterProvider, { router }),
127
+ ),
128
+ );
129
+ await created.flush();
130
+ await created.wait(20);
131
+ return created;
132
+ };
133
+
134
+ /**
135
+ * The write that was still in flight, landing after the one that followed it,
136
+ * and then the next render of the list it landed under.
137
+ */
138
+ const driftTo = async (
139
+ created: DomHarness,
140
+ router: AnyRouter,
141
+ q: string | undefined,
142
+ ): Promise<void> => {
143
+ void router.navigate({ to: ".", search: { q }, replace: true });
144
+ await created.wait(20);
145
+ created.click(created.byLabel(RENDER_AGAIN));
146
+ await created.wait(50);
147
+ };
148
+
149
+ describe("an address that drifts from the settled query", () => {
150
+ it("is written again, rather than left on the prefix", async () => {
151
+ const router = buildRouter(`${LIST_PATH}?q=invoice`);
152
+ const created = await mount(router, "invoice", "invoice");
153
+ assert.match(router.history.location.search, /q=invoice/);
154
+
155
+ await driftTo(created, router, "invo");
156
+
157
+ assert.match(
158
+ router.history.location.search,
159
+ /q=invoice/,
160
+ "the mirror never reconsidered the address it had already agreed with",
161
+ );
162
+ assert.equal(router.history.location.pathname, LIST_PATH);
163
+ });
164
+
165
+ it("is written again when the query was dropped from it entirely", async () => {
166
+ const router = buildRouter(`${LIST_PATH}?q=invoice`);
167
+ const created = await mount(router, "invoice", "invoice");
168
+
169
+ await driftTo(created, router, undefined);
170
+
171
+ assert.match(router.history.location.search, /q=invoice/);
172
+ });
173
+
174
+ it("leaves an address that already says the settled query alone", async () => {
175
+ const router = buildRouter(`${LIST_PATH}?q=invoice`);
176
+ const created = await mount(router, "invoice", "invoice");
177
+ const before = router.history.length;
178
+
179
+ await driftTo(created, router, "invoice");
180
+
181
+ assert.match(router.history.location.search, /q=invoice/);
182
+ assert.equal(router.history.length, before);
183
+ });
184
+
185
+ it("does not fight a query arriving mid-debounce", async () => {
186
+ // A deep link or a saved search lands while the field is still catching
187
+ // up. The committed query is the previous one, so the mirror has nothing
188
+ // settled to write and must not strip what just arrived.
189
+ const router = buildRouter(`${LIST_PATH}?q=receipts`);
190
+ const created = await mount(router, "invoice", "");
191
+
192
+ created.click(created.byLabel(RENDER_AGAIN));
193
+ await created.wait(50);
194
+
195
+ assert.match(router.history.location.search, /q=receipts/);
196
+ });
197
+ });
@@ -197,8 +197,9 @@ for (const listPath of LIST_PATHS) {
197
197
  * re-seeds on a view change, so the view key of the list and of the thread route
198
198
  * under it must be equal.
199
199
  *
200
- * Read off the matches the router resolved for a real address, so the ids under
201
- * test are the ones path segments produce rather than ids written out here.
200
+ * Read off the address the router committed for a real navigation, so the keys
201
+ * under test are the ones path segments produce rather than ids written out
202
+ * here.
202
203
  */
203
204
  describe("the view key of an open thread", () => {
204
205
  const viewKeyAt = async (
@@ -207,11 +208,7 @@ describe("the view key of an open thread", () => {
207
208
  ): Promise<string> => {
208
209
  const router = routerAt(listPath, href);
209
210
  await router.load();
210
- return mailViewKey(
211
- router.state.matches.map((match: { routeId: string }) => ({
212
- routeId: match.routeId,
213
- })),
214
- );
211
+ return mailViewKey(router.state.location.pathname);
215
212
  };
216
213
 
217
214
  for (const listPath of LIST_PATHS) {
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A query typed as the reader arrives on a list (#808).
3
+ *
4
+ * Three e2e specs hung their whole timeout on `waitForURL(/q=invoice/)` because
5
+ * the query never reached the address at all. Typing in the window between the
6
+ * router committing the next mailbox and React rendering it put the keystroke
7
+ * and the view change in one render, where the view-change re-seed took the
8
+ * field back to the destination's `q` — empty. The field held nothing, so the
9
+ * mirror had nothing to write, and no later render had any reason to reconsider.
10
+ *
11
+ * Driven through a real router, because the window under test is the router's
12
+ * own: the address commits before the matches swap, and a route the reader has
13
+ * not visited yet has its component to fetch before it can. Reasoning about
14
+ * that from the outside is not evidence, so the mailbox route here loads on a
15
+ * delay the way an unvisited one does.
16
+ *
17
+ * The other half is here too: a query ending with the view it was typed in
18
+ * (#47). That window is the same one, read the other way round — the address
19
+ * has moved and the matches have not — and the field must re-seed from the
20
+ * address rather than from the query the outgoing list still answers with.
21
+ * `lib/search-view.test.ts` drives the rules themselves.
22
+ */
23
+
24
+ import assert from "node:assert/strict";
25
+ import { afterEach, describe, it } from "node:test";
26
+ import {
27
+ type AnyRouter,
28
+ createMemoryHistory,
29
+ createRootRoute,
30
+ createRoute,
31
+ createRouter,
32
+ Outlet,
33
+ RouterProvider,
34
+ } from "@tanstack/react-router";
35
+ import { createElement, useState } from "react";
36
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
37
+ import { useSearchField } from "./useSearchField";
38
+
39
+ let harness: DomHarness | undefined;
40
+
41
+ afterEach(() => {
42
+ harness?.close();
43
+ harness = undefined;
44
+ });
45
+
46
+ // The router reads `self` at construction; the shared jsdom globals stop at
47
+ // `window`.
48
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
49
+
50
+ const FIELD_LABEL = "Search mail";
51
+ const MAILBOX_ID = "9f1c-abc";
52
+ const RENDER_AGAIN = "render again";
53
+
54
+ /** What the field committed, so a test can assert what the mirror would write. */
55
+ let committed = "";
56
+
57
+ /**
58
+ * The field, plus a way to make it render on demand. Typing renders it; a
59
+ * navigation on its own does not, because this harness does not propagate a
60
+ * router store change into React the way the running app does — so a test that
61
+ * only navigates is asking nothing.
62
+ */
63
+ function SearchField() {
64
+ const { searchInput, committedQuery, setSearchInput } = useSearchField();
65
+ const [renders, setRenders] = useState(0);
66
+ committed = committedQuery;
67
+ return createElement(
68
+ "div",
69
+ null,
70
+ createElement("input", {
71
+ "aria-label": FIELD_LABEL,
72
+ value: searchInput,
73
+ onChange: (event: { target: { value: string } }) =>
74
+ setSearchInput(event.target.value),
75
+ }),
76
+ createElement(
77
+ "button",
78
+ {
79
+ type: "button",
80
+ "aria-label": RENDER_AGAIN,
81
+ onClick: () => setRenders(renders + 1),
82
+ },
83
+ String(renders),
84
+ ),
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The mail shell over its four lists. Only the mailbox route loads on a delay:
90
+ * it stands for the list the reader has not been to yet, which is the one they
91
+ * navigate to and then type on.
92
+ */
93
+ const buildRouter = (
94
+ mailboxLoadMs: number,
95
+ href = "/mail/brief",
96
+ ): AnyRouter => {
97
+ const passthrough = (search: Record<string, unknown>) => search;
98
+ const rootRoute = createRootRoute({ component: Outlet });
99
+ const mailRoute = createRoute({
100
+ getParentRoute: () => rootRoute,
101
+ path: "/mail",
102
+ validateSearch: passthrough,
103
+ component: () => createElement(SearchField, null),
104
+ });
105
+ const briefRoute = createRoute({
106
+ getParentRoute: () => mailRoute,
107
+ path: "/brief",
108
+ validateSearch: passthrough,
109
+ component: () => null,
110
+ });
111
+ const mailboxRoute = createRoute({
112
+ getParentRoute: () => mailRoute,
113
+ path: "/$mailboxId",
114
+ validateSearch: passthrough,
115
+ loader: async () => {
116
+ await new Promise((resolve) => setTimeout(resolve, mailboxLoadMs));
117
+ return null;
118
+ },
119
+ component: () => null,
120
+ });
121
+ const routeTree = rootRoute.addChildren([
122
+ mailRoute.addChildren([briefRoute, mailboxRoute]),
123
+ ]);
124
+ return createRouter({
125
+ routeTree,
126
+ history: createMemoryHistory({ initialEntries: [href] }),
127
+ }) as unknown as AnyRouter;
128
+ };
129
+
130
+ const mount = async (
131
+ router: AnyRouter,
132
+ ): Promise<{ created: DomHarness; field: HTMLInputElement }> => {
133
+ const created = createDomHarness();
134
+ harness = created;
135
+ committed = "";
136
+ await router.load();
137
+ created.renderApp(createElement(RouterProvider, { router }));
138
+ await created.flush();
139
+ return {
140
+ created,
141
+ field: created.byLabel(FIELD_LABEL) as HTMLInputElement,
142
+ };
143
+ };
144
+
145
+ /** Long enough for the 200 ms debounce to settle on whatever the field holds. */
146
+ const DEBOUNCE_SETTLED = 400;
147
+
148
+ const openMailbox = (router: AnyRouter, mailboxId: string): void => {
149
+ void router.navigate({ to: "/mail/$mailboxId", params: { mailboxId } });
150
+ };
151
+
152
+ describe("a query the reader left behind on the previous list (#47)", () => {
153
+ // The sidebar link drops `q`, so the destination's address carries none. The
154
+ // field is re-seeded from that address and the mirror then has nothing to
155
+ // write, which is what keeps the query out of the next mailbox's URL.
156
+ for (const mailboxLoadMs of [0, 300]) {
157
+ it(`does not follow them into the next list (list ready in ${mailboxLoadMs}ms)`, async () => {
158
+ const router = buildRouter(mailboxLoadMs, "/mail/brief?q=invoice");
159
+ const { created, field } = await mount(router);
160
+ assert.equal(field.value, "invoice");
161
+
162
+ openMailbox(router, MAILBOX_ID);
163
+ await created.wait(10);
164
+ // The render inside the window the address has moved in and the
165
+ // matches have not: the outgoing list still answers `q=invoice`, and
166
+ // re-seeding from it hands the query to the mailbox being opened.
167
+ created.click(created.byLabel(RENDER_AGAIN));
168
+ await created.wait(mailboxLoadMs + DEBOUNCE_SETTLED);
169
+ created.click(created.byLabel(RENDER_AGAIN));
170
+ await created.wait(DEBOUNCE_SETTLED);
171
+
172
+ assert.equal(field.value, "");
173
+ assert.equal(committed, "");
174
+ });
175
+ }
176
+
177
+ it("arrives with a query the destination carries", async () => {
178
+ // The scope chip sends the reader to the brief to search everything, so
179
+ // the query travels in the address it navigates to and is kept.
180
+ const router = buildRouter(0, "/mail/9f1c-abc?q=invoice");
181
+ const { created, field } = await mount(router);
182
+
183
+ void router.navigate({ to: "/mail/brief", search: { q: "invoice" } });
184
+ await created.wait(10);
185
+ created.click(created.byLabel(RENDER_AGAIN));
186
+ await created.wait(DEBOUNCE_SETTLED);
187
+
188
+ assert.equal(field.value, "invoice");
189
+ assert.equal(committed, "invoice");
190
+ });
191
+ });
192
+
193
+ describe("a query typed as the reader arrives on a mailbox", () => {
194
+ // The address is already the mailbox — that is what `waitForURL` returns on
195
+ // — so the text was typed on the mailbox and is the mailbox's query.
196
+ for (const mailboxLoadMs of [0, 300]) {
197
+ it(`survives the view change it followed (list ready in ${mailboxLoadMs}ms)`, async () => {
198
+ const router = buildRouter(mailboxLoadMs);
199
+ const { created, field } = await mount(router);
200
+
201
+ openMailbox(router, MAILBOX_ID);
202
+ await created.wait(10);
203
+ assert.equal(
204
+ router.state.location.pathname,
205
+ `/mail/${MAILBOX_ID}`,
206
+ "the address moves before the list is on screen",
207
+ );
208
+
209
+ created.type(field, "invoice");
210
+ await created.wait(mailboxLoadMs + DEBOUNCE_SETTLED);
211
+
212
+ assert.equal(field.value, "invoice");
213
+ assert.equal(committed, "invoice");
214
+ });
215
+ }
216
+ });
@@ -0,0 +1,85 @@
1
+ import { useRouter, useRouterState } from "@tanstack/react-router";
2
+ import { useCallback, useState } from "react";
3
+ import { addressQuery, mailViewKey } from "@/lib/mail-route";
4
+ import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
5
+ import { useDebouncedValue } from "./useDebouncedValue";
6
+
7
+ export interface SearchField {
8
+ /** The live field text, which every search surface binds. */
9
+ searchInput: string;
10
+ /** The debounced query the search APIs run and the mirror writes. */
11
+ committedQuery: string;
12
+ /** The view the field is searching (`lib/mail-route.ts`). */
13
+ viewKey: string;
14
+ /** Every write to the field — typed, cleared, or a chip edited. */
15
+ setSearchInput: (query: string) => void;
16
+ }
17
+
18
+ /**
19
+ * The one search field, held by the /mail shell so it outlives every child
20
+ * route, and the query it commits.
21
+ *
22
+ * Within a view the URL's `q` seeds it and is a one-directional write target:
23
+ * the committed value drives the search APIs and each list's `useSearchMirror`
24
+ * writes it back. Across views the URL wins again — search is a mode of the
25
+ * view it was typed in, so leaving that view re-seeds the field from wherever
26
+ * the reader lands (#47), empty when the sidebar dropped `q` and the carried
27
+ * query when the scope chip sent them to the brief to search everything.
28
+ *
29
+ * The re-seed is adjusted during render, not in an effect: React's documented
30
+ * "adjusting state when a prop changes" pattern, so nothing is painted carrying
31
+ * the previous view's text. An effect would commit one frame with the stale
32
+ * query in it, which the mirror then has to be defended against.
33
+ *
34
+ * Text carries the view it was typed in, rather than the field simply taking
35
+ * whatever the last render's view key was (#808). The address moves a render
36
+ * ahead of React — a keystroke and the view change it followed arrive in the
37
+ * same render, and the re-seed cannot tell them apart from what it has on
38
+ * screen. Stamping the view at the keystroke, off the address the router has
39
+ * already committed, says which of the two came second: type into the field
40
+ * once the address names the next mailbox and that text is the new view's, not
41
+ * a leftover of the one being left. Without it a query typed in that window was
42
+ * wiped in the render that followed, and since the field then held nothing the
43
+ * mirror had nothing to write — the query never reached the URL at all.
44
+ *
45
+ * Which view and which query both come off the committed address, never one
46
+ * from the address and the other from the matched route. The matches swap a
47
+ * render behind the address, so mixing the two reads the destination's view
48
+ * against the outgoing list's `q` and re-seeds the field with the query the
49
+ * reader has just navigated away from — the stamp then calls that text the new
50
+ * view's, and nothing reconsiders it (#47).
51
+ */
52
+ export function useSearchField(): SearchField {
53
+ const router = useRouter();
54
+ const viewKey = useRouterState({
55
+ select: (state) => mailViewKey(state.location.pathname),
56
+ });
57
+ const urlQuery = useRouterState({
58
+ select: (state) => addressQuery(state.location.search),
59
+ });
60
+
61
+ const [searchInput, setInput] = useState(urlQuery);
62
+ const debouncedSearchInput = useDebouncedValue(searchInput, 200);
63
+
64
+ const [typedInView, setTypedInView] = useState(viewKey);
65
+ if (typedInView !== viewKey) {
66
+ const seeded = searchInputForView(typedInView, viewKey, urlQuery);
67
+ setTypedInView(viewKey);
68
+ if (seeded !== undefined) setInput(seeded);
69
+ }
70
+
71
+ const setSearchInput = useCallback(
72
+ (query: string) => {
73
+ setTypedInView(mailViewKey(router.state.location.pathname));
74
+ setInput(query);
75
+ },
76
+ [router],
77
+ );
78
+
79
+ return {
80
+ searchInput,
81
+ committedQuery: committedSearchQuery(searchInput, debouncedSearchInput),
82
+ viewKey,
83
+ setSearchInput,
84
+ };
85
+ }
@@ -1,6 +1,7 @@
1
- import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
2
- import { useEffect, useRef } from "react";
1
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
2
+ import { useEffect } from "react";
3
3
  import { useMailContext } from "@/lib/mail-context";
4
+ import { addressQuery } from "@/lib/mail-route";
4
5
  import { shouldMirrorQuery } from "@/lib/search-view";
5
6
  import { useIsComposing, useIsReplying } from "@/routing";
6
7
 
@@ -22,6 +23,20 @@ export type SearchMirrorTarget =
22
23
  * the query the user just left behind is never written onto the view they landed
23
24
  * on (`shouldMirrorQuery`).
24
25
  *
26
+ * It watches the URL as well as the field, so a settled query that the address
27
+ * stops agreeing with is written again rather than left (#808). Typing lands
28
+ * more than one navigation — the debounce settles mid-word, and the word is
29
+ * finished while that write is still in flight — and the two can commit out of
30
+ * order, leaving the address on the prefix. Comparing against the URL without
31
+ * re-running on it made that final: the field said `invoice`, the address said
32
+ * `invo`, and nothing was left to disagree with. Re-running cannot start a
33
+ * loop, because every write makes the URL equal the committed query and the
34
+ * next run has nothing to do. The `q` it compares against is the committed
35
+ * address's, the same place the pathname below comes from: the matched route
36
+ * answers with the outgoing list's query for a render after the address has
37
+ * moved on, and a mirror reading one from each would write against a URL that
38
+ * no longer exists.
39
+ *
25
40
  * It also writes only while the reader is still on this list. A list stays
26
41
  * mounted, effects and all, until the list they navigated to is ready to paint,
27
42
  * and by then the address is already the new one — so a debounce settling in
@@ -51,17 +66,14 @@ export type SearchMirrorTarget =
51
66
  export function useSearchMirror(target: SearchMirrorTarget): void {
52
67
  const navigate = useNavigate();
53
68
  const { searchInput, searchQuery: committedQuery } = useMailContext();
54
- const { q: urlQuery = "" } = useSearch({ from: "/mail" });
69
+ const urlQuery = useRouterState({
70
+ select: (s) => addressQuery(s.location.search),
71
+ });
55
72
  const pathname = useRouterState({ select: (s) => s.location.pathname });
56
73
  const isComposing = useIsComposing();
57
74
  const isReplying = useIsReplying();
58
75
  const isWriting = isComposing || isReplying;
59
76
 
60
- // Read at effect time rather than depended on: the URL is what the mirror
61
- // compares against, not what re-triggers it.
62
- const urlQueryRef = useRef(urlQuery);
63
- urlQueryRef.current = urlQuery;
64
-
65
77
  const { to } = target;
66
78
  const mailboxId = "params" in target ? target.params.mailboxId : undefined;
67
79
  const listPath = mailboxId ? `/mail/${mailboxId}` : to;
@@ -70,13 +82,13 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
70
82
  const mayWrite = shouldMirrorQuery({
71
83
  searchInput,
72
84
  committedQuery,
73
- urlQuery: urlQueryRef.current,
85
+ urlQuery,
74
86
  pathname,
75
87
  listPath,
76
88
  });
77
89
  if (!mayWrite) return;
78
90
  const queryGoesActive =
79
- Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
91
+ Boolean(committedQuery) && urlQuery !== committedQuery;
80
92
  // A message being written is the reader's own, not a leftover of the list
81
93
  // they were on, so a query going active narrows the list behind it and
82
94
  // leaves it where it is.
@@ -106,6 +118,7 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
106
118
  }, [
107
119
  searchInput,
108
120
  committedQuery,
121
+ urlQuery,
109
122
  navigate,
110
123
  to,
111
124
  mailboxId,