@remit/web-client 0.0.267 → 0.0.268

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.267",
3
+ "version": "0.0.268",
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,323 @@
1
+ /**
2
+ * The chrome the list header hands its body is one object, and the body is a
3
+ * virtualized list that re-renders every row when that object changes (#506).
4
+ *
5
+ * What is typed, where the caret is, which suggestion is highlighted and what
6
+ * the query converts to belong to the field and the wizard, not to the list.
7
+ * None of it changes anything the header shows, so the body must be handed back
8
+ * the object it already has.
9
+ *
10
+ * Driven through the real field and real keystrokes, in the real header, so
11
+ * every assertion is about the object the body receives rather than the shape of
12
+ * any one memo that fed it — `use-suggest-list.test.ts` is what pins the
13
+ * combobox props the field itself reads.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ Outlet,
25
+ RouterProvider,
26
+ } from "@tanstack/react-router";
27
+ import { createElement, Profiler, useCallback, useMemo, useState } from "react";
28
+ import {
29
+ type ListHeaderChrome,
30
+ useListHeaderChrome,
31
+ } from "@/lib/list-header-chrome";
32
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
33
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
34
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
35
+ import { type HttpMock, mockFetch } from "@/test-support/http";
36
+ import { MailListHeader } from "./MailListHeader";
37
+
38
+ const MAILBOX_ID = "11111111-1111-4111-8111-111111111111";
39
+ /** Tablet: the tier whose list header owns the field, rather than the top bar. */
40
+ const TABLET_WIDTH = 900;
41
+ /** A bare word, so the offer is token names and needs no lookup. */
42
+ const TYPED = "fro";
43
+ /** Still a bare word, so the query converts to the same clause and copy. */
44
+ const TYPED_NEXT = `${TYPED}g`;
45
+
46
+ let harness: DomHarness | undefined;
47
+ let http: HttpMock | undefined;
48
+ let chromes: ListHeaderChrome[] = [];
49
+ /** Commits React made in the header's subtree, so "it held" cannot mean "nothing ran". */
50
+ let commits = 0;
51
+ /** Which body the mounted view has: its own rows, or the read-only results panel. */
52
+ let resultsInBody = true;
53
+ /** The address the view mounts on; a `q` on it is a query already committed. */
54
+ let initialPath = `/mail/${MAILBOX_ID}`;
55
+ /**
56
+ * The query the shell has committed, as the /mail shell would hand it down: the
57
+ * field is mirrored to the URL on a debounce, so a view mounted on an address
58
+ * carrying `q` reads a query the next keystroke does not move. Unset, the typed
59
+ * text answers for both, which is the mid-debounce case the other tests are in.
60
+ */
61
+ let committedQuery: string | undefined;
62
+
63
+ afterEach(() => {
64
+ harness?.close();
65
+ harness = undefined;
66
+ http?.restore();
67
+ http = undefined;
68
+ chromes = [];
69
+ commits = 0;
70
+ resultsInBody = true;
71
+ initialPath = `/mail/${MAILBOX_ID}`;
72
+ committedQuery = undefined;
73
+ });
74
+
75
+ // The router reads `self` at construction; the shared jsdom globals stop at
76
+ // `window`.
77
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
78
+
79
+ /** Stands in for the list: it takes the chrome and renders the field slot. */
80
+ function ChromeProbe() {
81
+ const chrome = useListHeaderChrome();
82
+ chromes.push(chrome);
83
+ return createElement("div", null, chrome.searchField);
84
+ }
85
+
86
+ function Header() {
87
+ const [query, setQuery] = useState(TYPED);
88
+ const clear = useCallback(() => setQuery(""), []);
89
+ const context = useMemo<MailContextValue>(
90
+ () => ({
91
+ accounts: [],
92
+ mailboxNameIndex: new Map(),
93
+ accountNameIndex: new Map(),
94
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
95
+ searchQuery: committedQuery ?? query,
96
+ searchInput: query,
97
+ searchViewKey: "list",
98
+ onSearchChange: setQuery,
99
+ onSearchClear: clear,
100
+ onSearchClearQuery: clear,
101
+ intelligenceOpen: false,
102
+ onToggleIntelligence: () => undefined,
103
+ onRaiseIntelligence: () => undefined,
104
+ }),
105
+ [query, clear],
106
+ );
107
+ return createElement(
108
+ MailContext.Provider,
109
+ { value: context },
110
+ createElement(
111
+ Profiler,
112
+ {
113
+ id: "list-header",
114
+ onRender: () => {
115
+ commits += 1;
116
+ },
117
+ },
118
+ createElement(MailListHeader, {
119
+ title: "Inbox",
120
+ unreadCount: null,
121
+ // Set, the body renders the committed search itself and the read-only
122
+ // results panel stays out of the chrome; unset, the panel rides in the
123
+ // chrome, which is the other thing a keystroke must not churn.
124
+ searchResultsInBody: resultsInBody,
125
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
126
+ children: createElement(ChromeProbe),
127
+ }),
128
+ ),
129
+ );
130
+ }
131
+
132
+ const testRouter = (): AnyRouter => {
133
+ const rootRoute = createRootRoute({ component: Outlet });
134
+ const mailRoute = createRoute({
135
+ getParentRoute: () => rootRoute,
136
+ path: "/mail",
137
+ validateSearch: (search: Record<string, unknown>) => search,
138
+ component: Outlet,
139
+ });
140
+ const mailboxRoute = createRoute({
141
+ getParentRoute: () => mailRoute,
142
+ path: "/$mailboxId",
143
+ component: Header,
144
+ });
145
+ return createRouter({
146
+ routeTree: rootRoute.addChildren([mailRoute.addChildren([mailboxRoute])]),
147
+ history: createMemoryHistory({ initialEntries: [initialPath] }),
148
+ }) as unknown as AnyRouter;
149
+ };
150
+
151
+ /** Mount the header with a query typed and the field holding focus. */
152
+ const mount = async (
153
+ options: { resultsPanelInChrome?: boolean; committedQuery?: string } = {},
154
+ ): Promise<{
155
+ mounted: DomHarness;
156
+ field: HTMLInputElement;
157
+ }> => {
158
+ resultsInBody = options.resultsPanelInChrome !== true;
159
+ committedQuery = options.committedQuery;
160
+ initialPath = committedQuery
161
+ ? `/mail/${MAILBOX_ID}?q=${committedQuery}`
162
+ : `/mail/${MAILBOX_ID}`;
163
+ http = mockFetch(() => ({ items: [] }));
164
+ const router = testRouter();
165
+ await router.load();
166
+ const mounted = createDomHarness({ viewportWidth: TABLET_WIDTH });
167
+ harness = mounted;
168
+ mounted.renderApp(createElement(RouterProvider, { router }));
169
+ await mounted.waitFor(
170
+ () => mounted.query('[aria-label="Search mail"]') !== null,
171
+ "the header's search field",
172
+ );
173
+ const field = mounted.query<HTMLInputElement>('[aria-label="Search mail"]');
174
+ assert.ok(field, "the header rendered no search field");
175
+ mounted.dispatch(field, new Event("focusin", { bubbles: true }));
176
+ await mounted.waitFor(
177
+ () => field.getAttribute("aria-expanded") === "true",
178
+ "the suggestion list to open",
179
+ );
180
+ return { mounted, field };
181
+ };
182
+
183
+ const press = (mounted: DomHarness, field: Element, key: string) => {
184
+ mounted.dispatch(
185
+ field,
186
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
187
+ );
188
+ };
189
+
190
+ describe("the chrome the list header hands its body (#506)", () => {
191
+ it("is the same object across a keystroke that only moves the field's highlight", async () => {
192
+ const { mounted, field } = await mount();
193
+ const before = chromes.at(-1);
194
+ assert.ok(before, "the body was never handed a chrome");
195
+ const handedBefore = chromes.length;
196
+ const commitsBefore = commits;
197
+
198
+ press(mounted, field, "ArrowDown");
199
+ await mounted.flush();
200
+
201
+ assert.ok(
202
+ commits > commitsBefore,
203
+ "the keystroke committed no render at all, so the memo was never put to the test",
204
+ );
205
+ assert.ok(
206
+ field.getAttribute("aria-activedescendant"),
207
+ "the keystroke did not move the highlight, so nothing was put to the test",
208
+ );
209
+ assert.equal(
210
+ chromes.length,
211
+ handedBefore,
212
+ "a highlight move in the field re-rendered the list below it",
213
+ );
214
+ assert.equal(
215
+ chromes.at(-1),
216
+ before,
217
+ "a highlight move in the field pushed a new chrome at the list below it",
218
+ );
219
+ });
220
+
221
+ // The symptom #506 was filed on: a mailbox on tablet, its query already in the
222
+ // URL, its own rows in the body — and a character typed into the field
223
+ // repainting all of them. Everything a keystroke moves here (the parse, the
224
+ // conversion the wizard opens on, the copy the make-this-a-filter row shows)
225
+ // is derived from the text, so the chrome holds only if none of it rides on it.
226
+ //
227
+ // The typed text lives above the header, so the list is re-rendered either
228
+ // way — what it must be handed is the chrome it already has, which is what
229
+ // keeps its own rows off the render path.
230
+ it("is the same object across a character typed over a committed query", async () => {
231
+ const { mounted, field } = await mount({ committedQuery: TYPED });
232
+ const before = chromes.at(-1);
233
+ assert.ok(before, "the body was never handed a chrome");
234
+ const handedBefore = chromes.length;
235
+ const commitsBefore = commits;
236
+
237
+ mounted.type(field, TYPED_NEXT);
238
+ await mounted.flush();
239
+
240
+ assert.equal(
241
+ field.value,
242
+ TYPED_NEXT,
243
+ "the character never reached the field",
244
+ );
245
+ assert.ok(
246
+ commits > commitsBefore,
247
+ "the keystroke committed no render at all, so the memo was never put to the test",
248
+ );
249
+ assert.ok(
250
+ chromes.length > handedBefore,
251
+ "the list was never re-rendered, so it was never handed a second chrome to compare",
252
+ );
253
+ // Identity, not `assert.equal`: a failure there renders a diff of two
254
+ // chromes full of React elements, and that is what runs the reporter out of
255
+ // memory rather than reporting the bug.
256
+ assert.ok(
257
+ chromes.at(-1) === before,
258
+ "a character typed over a committed query pushed a new chrome at the list below it",
259
+ );
260
+ });
261
+
262
+ it("is the same object for a view whose results panel rides in the chrome", async () => {
263
+ const { mounted, field } = await mount({ resultsPanelInChrome: true });
264
+ const before = chromes.at(-1);
265
+ assert.ok(before?.searchResults, "the panel never reached the chrome");
266
+ const handedBefore = chromes.length;
267
+ const commitsBefore = commits;
268
+
269
+ press(mounted, field, "ArrowDown");
270
+ await mounted.flush();
271
+
272
+ assert.ok(
273
+ commits > commitsBefore,
274
+ "the keystroke committed no render at all, so the memo was never put to the test",
275
+ );
276
+ assert.equal(
277
+ chromes.length,
278
+ handedBefore,
279
+ "a highlight move in the field re-rendered the list below it",
280
+ );
281
+ assert.equal(chromes.at(-1), before);
282
+ });
283
+
284
+ it("is the same object across the field taking and losing focus", async () => {
285
+ const { mounted, field } = await mount();
286
+ const before = chromes.at(-1);
287
+ const handedBefore = chromes.length;
288
+ const commitsBefore = commits;
289
+
290
+ mounted.dispatch(field, new Event("focusout", { bubbles: true }));
291
+ await mounted.flush();
292
+ mounted.dispatch(field, new Event("focusin", { bubbles: true }));
293
+ await mounted.waitFor(
294
+ () => field.getAttribute("aria-expanded") === "true",
295
+ "the suggestion list to open again",
296
+ );
297
+
298
+ assert.ok(
299
+ commits > commitsBefore,
300
+ "focus moved nothing, so the memo was never put to the test",
301
+ );
302
+ assert.equal(
303
+ chromes.length,
304
+ handedBefore,
305
+ "focusing the field re-rendered the list below it",
306
+ );
307
+ assert.equal(chromes.at(-1), before);
308
+ });
309
+
310
+ // The key handler is stable now, so it has to read the highlight the field
311
+ // is showing rather than the one it was bound with.
312
+ it("still takes the highlighted suggestion on Enter", async () => {
313
+ const { mounted, field } = await mount();
314
+
315
+ press(mounted, field, "ArrowDown");
316
+ await mounted.flush();
317
+ press(mounted, field, "Enter");
318
+ await mounted.flush();
319
+
320
+ assert.notEqual(field.value, TYPED, "Enter left the query as it was typed");
321
+ assert.match(field.value, /^from:/);
322
+ });
323
+ });
@@ -60,8 +60,10 @@ import {
60
60
  } from "@remit/ui";
61
61
  import { Menu, Search, X } from "lucide-react";
62
62
  import {
63
+ createContext,
63
64
  type ReactNode,
64
65
  useCallback,
66
+ useContext,
65
67
  useEffect,
66
68
  useMemo,
67
69
  useRef,
@@ -78,6 +80,7 @@ import {
78
80
  import { useMailContext } from "@/lib/mail-context";
79
81
  import { convertSearchToRule } from "@/lib/organize/search-to-rule";
80
82
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
83
+ import { SearchConversionContext } from "@/lib/search-conversion";
81
84
  import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
82
85
  import { applySearchSuggestion } from "@/lib/search-suggestions";
83
86
  import { showInlineSearchResults } from "@/lib/search-surface";
@@ -157,6 +160,60 @@ export interface MailListHeaderProps {
157
160
  refreshControl?: ReactNode;
158
161
  }
159
162
 
163
+ interface HeaderSearchState {
164
+ suggest: SearchFieldSuggest;
165
+ /** Drop the query and put the field away. */
166
+ onClose: () => void;
167
+ }
168
+
169
+ const HeaderSearchContext = createContext<HeaderSearchState | null>(null);
170
+
171
+ /**
172
+ * The expanded field, which subscribes to its own state instead of being handed
173
+ * it (#506).
174
+ *
175
+ * What is typed, where the caret is and which suggestion is highlighted change
176
+ * on every keystroke and belong to the field alone. Building the field into the
177
+ * chrome object made each of those a new chrome, and the chrome reaches the
178
+ * virtualized list through context, so the list re-rendered for a character it
179
+ * does not show. The chrome carries the slot; the slot reads the rest itself.
180
+ */
181
+ function ListHeaderSearchField() {
182
+ const search = useContext(HeaderSearchContext);
183
+ const { searchInput, onSearchChange, onSearchClear } = useMailContext();
184
+ // Only `MailListHeader` puts this slot on the chrome, and it does so inside
185
+ // its own provider, so there is no reachable path here without one. Say so
186
+ // rather than rendering nothing: a search field that silently disappears is
187
+ // indistinguishable from a view that has no search.
188
+ if (!search)
189
+ throw new Error(
190
+ "ListHeaderSearchField rendered outside MailListHeader's HeaderSearchContext provider",
191
+ );
192
+ return (
193
+ <>
194
+ <div className="min-w-0 flex-1">
195
+ <SearchBar
196
+ value={searchInput}
197
+ onChange={onSearchChange}
198
+ onClear={onSearchClear}
199
+ globalFocusKey={false}
200
+ showClearButton={false}
201
+ suggest={search.suggest}
202
+ />
203
+ </div>
204
+ <Button
205
+ variant="ghost"
206
+ size="touch"
207
+ icon={<X className="size-5" />}
208
+ onClick={search.onClose}
209
+ aria-label="Close search"
210
+ className="shrink-0"
211
+ />
212
+ <FilterToggle />
213
+ </>
214
+ );
215
+ }
216
+
160
217
  export function MailListHeader({
161
218
  title,
162
219
  unreadCount,
@@ -241,9 +298,9 @@ export function MailListHeader({
241
298
  if (suggestion) applySuggestion(suggestion);
242
299
  },
243
300
  });
244
- // Stable while nothing about the field changes: it reaches the list through
245
- // the header chrome, and a fresh object every render would re-render the
246
- // virtualized body for nothing.
301
+ // Stable while nothing about the field changes: it reaches the field through
302
+ // its own context rather than the chrome, so moving the highlight repaints
303
+ // the field and leaves the list under it alone.
247
304
  const { comboboxProps, handleKeyDown } = suggest;
248
305
  const searchSuggest = useMemo<SearchFieldSuggest>(
249
306
  () => ({
@@ -255,6 +312,14 @@ export function MailListHeader({
255
312
  }),
256
313
  [comboboxProps, handleKeyDown, caretRequest],
257
314
  );
315
+ const closeSearch = useCallback(() => {
316
+ onSearchClear();
317
+ setSearchOpen(false);
318
+ }, [onSearchClear]);
319
+ const headerSearch = useMemo<HeaderSearchState>(
320
+ () => ({ suggest: searchSuggest, onClose: closeSearch }),
321
+ [searchSuggest, closeSearch],
322
+ );
258
323
  // Under the field and in flow, on both tiers: a phone's soft keyboard owns
259
324
  // the lower half of the screen, and a list floating over the field would
260
325
  // cover the query it is completing.
@@ -286,21 +351,29 @@ export function MailListHeader({
286
351
  () => parseSearchTokens(searchInput, { mailboxesByName, accountsByName }),
287
352
  [searchInput, mailboxesByName, accountsByName],
288
353
  );
289
- const tokenChips = parsed.tokens.map((token) => ({
290
- label: searchTokenLabel(token),
291
- onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
292
- }));
293
- const topMatches = searchResults ?? [];
294
- const related = relatedResults ?? [];
354
+ const tokenChips = useMemo(
355
+ () =>
356
+ parsed.tokens.map((token) => ({
357
+ label: searchTokenLabel(token),
358
+ onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
359
+ })),
360
+ [parsed, searchInput, onSearchChange],
361
+ );
362
+ const topMatches = useMemo(() => searchResults ?? [], [searchResults]);
363
+ const related = useMemo(() => relatedResults ?? [], [relatedResults]);
295
364
  // Always offer both sections while a query is present; the kit drops the empty
296
365
  // ones, so a "Related"-only hit still shows and two empties fall to its empty
297
366
  // state. The empty-query case (recent searches) is the kit's job.
298
- const sections: SearchResultSection[] = hasQuery
299
- ? [
300
- { id: "top", label: searchResultsLabel, results: topMatches },
301
- { id: "related", label: relatedResultsLabel, results: related },
302
- ]
303
- : [];
367
+ const sections = useMemo<SearchResultSection[]>(
368
+ () =>
369
+ hasQuery
370
+ ? [
371
+ { id: "top", label: searchResultsLabel, results: topMatches },
372
+ { id: "related", label: relatedResultsLabel, results: related },
373
+ ]
374
+ : [],
375
+ [hasQuery, searchResultsLabel, relatedResultsLabel, topMatches, related],
376
+ );
304
377
  // Skeleton only while nothing is in yet — once either section has rows, show
305
378
  // them and let the other arrive (or not). Keeps the two sources independent.
306
379
  const hasAnyResult = topMatches.length + related.length > 0;
@@ -312,33 +385,43 @@ export function MailListHeader({
312
385
  const { scope } = useSearchScope(accounts);
313
386
  const browsed = useBrowsedList();
314
387
  const scopedMailboxId = routeMailboxId(browsed);
315
- const routeScope = resultsScopeForRoute(
316
- browsed,
317
- scope,
318
- scopedMailboxId ? resultFolderIndex.get(scopedMailboxId)?.role : undefined,
388
+ const scopedRole = scopedMailboxId
389
+ ? resultFolderIndex.get(scopedMailboxId)?.role
390
+ : undefined;
391
+ const routeScope = useMemo(
392
+ () => resultsScopeForRoute(browsed, scope, scopedRole),
393
+ [browsed, scope, scopedRole],
319
394
  );
320
395
 
321
396
  // The offer counts results for the *committed* query, so that is the query it
322
397
  // carries into Spam. The count the banner states is the results list's own,
323
398
  // over every row it held out; the app supplies only where "Go to Spam" goes.
324
- const spamOffer =
325
- routeScope.kind === "global"
326
- ? spamOfferForResults([...topMatches, ...related])
327
- : undefined;
328
- const resultsScope: SearchScope = spamOffer
329
- ? {
330
- kind: "global",
331
- onScopeToSpam: () =>
332
- scopeSearchToMailbox(spamOffer.mailboxId, searchQuery),
333
- }
334
- : routeScope;
399
+ const spamOffer = useMemo(
400
+ () =>
401
+ routeScope.kind === "global"
402
+ ? spamOfferForResults([...topMatches, ...related])
403
+ : undefined,
404
+ [routeScope, topMatches, related],
405
+ );
406
+ const resultsScope = useMemo<SearchScope>(
407
+ () =>
408
+ spamOffer
409
+ ? {
410
+ kind: "global",
411
+ onScopeToSpam: () =>
412
+ scopeSearchToMailbox(spamOffer.mailboxId, searchQuery),
413
+ }
414
+ : routeScope,
415
+ [spamOffer, routeScope, scopeSearchToMailbox, searchQuery],
416
+ );
335
417
 
336
418
  // Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
337
419
  // entry. It opens on the properties step with the clauses `convertSearchToRule`
338
420
  // derives from the query, nothing ticked, and the notice for what the query
339
- // could not be turned into. The conversion is computed once, here, and handed
340
- // to the wizard through the chrome — the reason the affordance gives and the
341
- // clauses the wizard seeds from are the same answer. The literal filter cannot
421
+ // could not be turned into. The conversion is computed once, here, and reaches
422
+ // the wizard through its own context — the reason the affordance gives and the
423
+ // clauses the wizard seeds from are the same answer, without the per-keystroke
424
+ // object riding on the chrome the list reads. The literal filter cannot
342
425
  // reproduce the search's semantic reach, so the conversion states it whenever
343
426
  // the search surfaced a "Related" section — a direct signal, read here from
344
427
  // the semantic results, never a capability probe.
@@ -358,28 +441,32 @@ export function MailListHeader({
358
441
  [parsed, searchHadSemanticReach],
359
442
  );
360
443
  const searchConversion = hasQuery ? conversion : undefined;
361
- const makeFilter = hasQuery
362
- ? {
363
- // The wizard is a full-screen surface, so the phone takeover it was
364
- // pressed from stands down rather than sitting under it — and the list
365
- // underneath, which hosts the wizard, is mounted again by the time the
366
- // step lands.
367
- onClick: () => {
368
- setSearchOpen(false);
369
- openWizard("properties", "search");
370
- },
371
- blockedReason: isConvertible(conversion)
372
- ? undefined
373
- : makeFilterBlockedCopy(
374
- conversion.droppedFacets.map((facet) => facet.label),
375
- ),
376
- }
377
- : undefined;
444
+ // The wizard is a full-screen surface, so the phone takeover it was pressed
445
+ // from stands down rather than sitting under it — and the list underneath,
446
+ // which hosts the wizard, is mounted again by the time the step lands.
447
+ const openFilterWizard = useCallback(() => {
448
+ setSearchOpen(false);
449
+ openWizard("properties", "search");
450
+ }, [openWizard]);
451
+ // Keyed on the copy the affordance shows, not on the conversion it is derived
452
+ // from: the conversion is a fresh object for every character typed, while the
453
+ // reason it yields is the same string for all of them, and this row travels to
454
+ // the virtualized list on the chrome (#506).
455
+ const blockedReason = isConvertible(conversion)
456
+ ? undefined
457
+ : makeFilterBlockedCopy(
458
+ conversion.droppedFacets.map((facet) => facet.label),
459
+ );
460
+ const makeFilter = useMemo(
461
+ () => (hasQuery ? { onClick: openFilterWizard, blockedReason } : undefined),
462
+ [hasQuery, blockedReason, openFilterWizard],
463
+ );
378
464
  // Handed to the bar rather than rendered here: the bar knows whether rows
379
465
  // are ticked, and a selection's own verbs own the surface while they are up.
380
- const makeFilterAction = makeFilter ? (
381
- <MakeFilterAction {...makeFilter} />
382
- ) : null;
466
+ const makeFilterAction = useMemo(
467
+ () => (makeFilter ? <MakeFilterAction {...makeFilter} /> : null),
468
+ [makeFilter],
469
+ );
383
470
 
384
471
  // Tablet + desktop keep the inline toolbar search; while a query is being
385
472
  // typed the list-pane body swaps to the same sectioned results the phone
@@ -395,19 +482,32 @@ export function MailListHeader({
395
482
  hasCommittedQuery: searchQuery.trim().length > 0,
396
483
  bodyRendersCommittedResults: searchResultsInBody,
397
484
  });
398
- const handleSelectInlineResult = (result: SearchResult) => {
399
- setRecentSearches(saveRecentSearch(searchInput));
400
- onSelectSearchResult?.(result);
401
- };
402
- const results = (
403
- <SearchResults
404
- value={searchInput}
405
- sections={sections}
406
- loading={resultsLoading}
407
- onSelectResult={handleSelectInlineResult}
408
- tokens={tokenChips}
409
- scope={resultsScope}
410
- />
485
+ const handleSelectInlineResult = useCallback(
486
+ (result: SearchResult) => {
487
+ setRecentSearches(saveRecentSearch(searchInput));
488
+ onSelectSearchResult?.(result);
489
+ },
490
+ [searchInput, onSelectSearchResult],
491
+ );
492
+ const results = useMemo(
493
+ () => (
494
+ <SearchResults
495
+ value={searchInput}
496
+ sections={sections}
497
+ loading={resultsLoading}
498
+ onSelectResult={handleSelectInlineResult}
499
+ tokens={tokenChips}
500
+ scope={resultsScope}
501
+ />
502
+ ),
503
+ [
504
+ searchInput,
505
+ sections,
506
+ resultsLoading,
507
+ handleSelectInlineResult,
508
+ tokenChips,
509
+ resultsScope,
510
+ ],
411
511
  );
412
512
  // The header lives inside `children` for every view whose selection sits
413
513
  // below this one, so swapping the body out from here would take the header
@@ -416,9 +516,13 @@ export function MailListHeader({
416
516
  // put it where their own rows go; the brief, whose header this component
417
517
  // renders, keeps the plain swap.
418
518
  const bodyOwnsHeader = selectionBar === undefined;
419
- const resultsPane = showInlineResults ? (
420
- <div className="h-full overflow-y-auto">{results}</div>
421
- ) : null;
519
+ const resultsPane = useMemo(
520
+ () =>
521
+ showInlineResults ? (
522
+ <div className="h-full overflow-y-auto">{results}</div>
523
+ ) : null,
524
+ [showInlineResults, results],
525
+ );
422
526
  const body = resultsPane && !bodyOwnsHeader ? resultsPane : children;
423
527
  const chromeResults = resultsPane && bodyOwnsHeader ? resultsPane : null;
424
528
 
@@ -435,7 +539,6 @@ export function MailListHeader({
435
539
  title,
436
540
  searchResults: chromeResults,
437
541
  makeFilterSlot: makeFilterAction,
438
- searchConversion,
439
542
  navSlot: layout && !layout.showNavPane && (
440
543
  <Button
441
544
  variant="ghost"
@@ -467,32 +570,7 @@ export function MailListHeader({
467
570
  className="shrink-0"
468
571
  />
469
572
  ),
470
- searchField: searchExpanded && (
471
- <>
472
- <div className="min-w-0 flex-1">
473
- <SearchBar
474
- value={searchInput}
475
- onChange={onSearchChange}
476
- onClear={onSearchClear}
477
- globalFocusKey={false}
478
- showClearButton={false}
479
- suggest={searchSuggest}
480
- />
481
- </div>
482
- <Button
483
- variant="ghost"
484
- size="touch"
485
- icon={<X className="size-5" />}
486
- onClick={() => {
487
- onSearchClear();
488
- setSearchOpen(false);
489
- }}
490
- aria-label="Close search"
491
- className="shrink-0"
492
- />
493
- <FilterToggle />
494
- </>
495
- ),
573
+ searchField: searchExpanded && <ListHeaderSearchField />,
496
574
  }),
497
575
  [
498
576
  title,
@@ -500,13 +578,8 @@ export function MailListHeader({
500
578
  layout,
501
579
  ownsSearch,
502
580
  searchExpanded,
503
- searchInput,
504
- onSearchChange,
505
- onSearchClear,
506
- searchSuggest,
507
581
  chromeResults,
508
582
  makeFilterAction,
509
- searchConversion,
510
583
  refreshControl,
511
584
  ],
512
585
  );
@@ -518,41 +591,49 @@ export function MailListHeader({
518
591
  onSelectSearchResult?.(result);
519
592
  };
520
593
  return (
521
- <ListHeaderChromeContext.Provider value={chrome}>
522
- <MobileSearchView
523
- value={searchInput}
524
- onChange={onSearchChange}
525
- onClear={onSearchClear}
526
- onCancel={() => {
527
- setSearchOpen(false);
528
- onSearchClear();
529
- }}
530
- filter={searchFilter}
531
- recentSearches={recentSearches}
532
- onPickRecent={onSearchChange}
533
- makeFilter={makeFilter}
534
- sections={sections}
535
- loading={resultsLoading}
536
- onSelectResult={handleSelectResult}
537
- tokens={tokenChips}
538
- scope={resultsScope}
539
- suggest={searchSuggest}
540
- suggestList={suggestList}
541
- />
542
- {paneOverlay}
543
- </ListHeaderChromeContext.Provider>
594
+ <HeaderSearchContext.Provider value={headerSearch}>
595
+ <SearchConversionContext.Provider value={searchConversion}>
596
+ <ListHeaderChromeContext.Provider value={chrome}>
597
+ <MobileSearchView
598
+ value={searchInput}
599
+ onChange={onSearchChange}
600
+ onClear={onSearchClear}
601
+ onCancel={() => {
602
+ setSearchOpen(false);
603
+ onSearchClear();
604
+ }}
605
+ filter={searchFilter}
606
+ recentSearches={recentSearches}
607
+ onPickRecent={onSearchChange}
608
+ makeFilter={makeFilter}
609
+ sections={sections}
610
+ loading={resultsLoading}
611
+ onSelectResult={handleSelectResult}
612
+ tokens={tokenChips}
613
+ scope={resultsScope}
614
+ suggest={searchSuggest}
615
+ suggestList={suggestList}
616
+ />
617
+ {paneOverlay}
618
+ </ListHeaderChromeContext.Provider>
619
+ </SearchConversionContext.Provider>
620
+ </HeaderSearchContext.Provider>
544
621
  );
545
622
  }
546
623
 
547
624
  return (
548
- <ListHeaderChromeContext.Provider value={chrome}>
549
- <section className="relative flex h-full w-full flex-col bg-surface">
550
- {selectionBar?.(chrome)}
551
- {suggestList}
552
- <div className="min-h-0 flex-1">{body}</div>
553
- {footer}
554
- {paneOverlay}
555
- </section>
556
- </ListHeaderChromeContext.Provider>
625
+ <HeaderSearchContext.Provider value={headerSearch}>
626
+ <SearchConversionContext.Provider value={searchConversion}>
627
+ <ListHeaderChromeContext.Provider value={chrome}>
628
+ <section className="relative flex h-full w-full flex-col bg-surface">
629
+ {selectionBar?.(chrome)}
630
+ {suggestList}
631
+ <div className="min-h-0 flex-1">{body}</div>
632
+ {footer}
633
+ {paneOverlay}
634
+ </section>
635
+ </ListHeaderChromeContext.Provider>
636
+ </SearchConversionContext.Provider>
637
+ </HeaderSearchContext.Provider>
557
638
  );
558
639
  }
@@ -57,7 +57,6 @@ import type {
57
57
  BulkRunOutcome,
58
58
  } from "@/lib/bulk-actions";
59
59
  import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
60
- import { useListHeaderChrome } from "@/lib/list-header-chrome";
61
60
  import { useMailContext } from "@/lib/mail-context";
62
61
  import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
63
62
  import {
@@ -73,6 +72,7 @@ import {
73
72
  } from "@/lib/organize/rule-model";
74
73
  import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
75
74
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
75
+ import { useSearchConversion } from "@/lib/search-conversion";
76
76
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
77
77
  import { useWizardEntryValue, useWizardStep } from "@/routing";
78
78
  import { organizeRunState } from "./organize-run-state";
@@ -1105,7 +1105,7 @@ function SelectionWizardSession({
1105
1105
  */
1106
1106
  export function SelectionWizardHost(props: SelectionWizardHostProps) {
1107
1107
  const fromSearch = useWizardEntryValue() === "search";
1108
- const { searchConversion } = useListHeaderChrome();
1108
+ const searchConversion = useSearchConversion();
1109
1109
  const { accounts } = useMailContext();
1110
1110
  const { step, goToStep, goBack, closeWizard } = useWizardStep(
1111
1111
  fromSearch ? "properties" : "match",
@@ -1,5 +1,5 @@
1
1
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { useCallback } from "react";
2
+ import { useCallback, useMemo } from "react";
3
3
  import { useMailContext } from "@/lib/mail-context";
4
4
  import {
5
5
  SEARCH_SCOPE_CHIP_ID,
@@ -26,7 +26,10 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
26
26
  const { searchInput } = useMailContext();
27
27
  const mailboxName = useCurrentMailboxName({ accounts });
28
28
  const browsed = useBrowsedList();
29
- const scope = searchScopeForRoute(browsed, mailboxName);
29
+ const scope = useMemo(
30
+ () => searchScopeForRoute(browsed, mailboxName),
31
+ [browsed, mailboxName],
32
+ );
30
33
 
31
34
  // Takes the chip id the field removed rather than assuming which chip that
32
35
  // was. The bar owns one chip today; keying on the id means a second one
@@ -1,4 +1,3 @@
1
- import type { SearchConversion } from "@remit/ui";
2
1
  import { createContext, type ReactNode, useContext } from "react";
3
2
 
4
3
  /**
@@ -11,6 +10,11 @@ import { createContext, type ReactNode, useContext } from "react";
11
10
  * Handing the built nodes down keeps one definition of the chrome and keeps the
12
11
  * bar's two states in the same commit: nothing here is derived from a state
13
12
  * update that lands after paint.
13
+ *
14
+ * The body is a virtualized list, so it re-renders every row when this object
15
+ * changes. Nothing that moves with a keystroke belongs on it (#506) — what is
16
+ * typed, where the caret is and what the query converts to reach their readers
17
+ * through their own contexts.
14
18
  */
15
19
  export interface ListHeaderChrome {
16
20
  /** The view's name, carried while nothing is ticked. */
@@ -32,15 +36,6 @@ export interface ListHeaderChrome {
32
36
  searchResults: ReactNode;
33
37
  /** The search's make-this-a-filter row, up only while nothing is ticked. */
34
38
  makeFilterSlot: ReactNode;
35
- /**
36
- * The active query as clauses, which is what the wizard's search entry opens
37
- * on (#484). It travels with the chrome for the same reason the rest does:
38
- * the query belongs to `MailListHeader` and the wizard is mounted by the body
39
- * below it, and one conversion computed in one place is what keeps the reason
40
- * the affordance gives and the clauses the wizard seeds from disagreeing.
41
- * Absent when no query is up.
42
- */
43
- searchConversion?: SearchConversion;
44
39
  }
45
40
 
46
41
  const NO_CHROME: ListHeaderChrome = {
@@ -0,0 +1,23 @@
1
+ import type { SearchConversion } from "@remit/ui";
2
+ import { createContext, useContext } from "react";
3
+
4
+ /**
5
+ * The active query as clauses, which is what the wizard's search entry opens on
6
+ * (#484). One conversion, computed once in `MailListHeader`, so the reason the
7
+ * make-this-a-filter affordance gives and the clauses the wizard seeds from
8
+ * cannot disagree.
9
+ *
10
+ * Its own context rather than a field on the list header chrome (#506): the
11
+ * conversion is derived from the query text, so it is a new object on every
12
+ * keystroke, and carrying it on the chrome meant the chrome was too — which
13
+ * re-rendered every row of the virtualized list for a character the list does
14
+ * not show. The wizard is the only reader, and it is mounted once.
15
+ *
16
+ * Absent when no query is up.
17
+ */
18
+ export const SearchConversionContext = createContext<
19
+ SearchConversion | undefined
20
+ >(undefined);
21
+
22
+ export const useSearchConversion = (): SearchConversion | undefined =>
23
+ useContext(SearchConversionContext);
@@ -4,7 +4,7 @@ import {
4
4
  useParams,
5
5
  useRouterState,
6
6
  } from "@tanstack/react-router";
7
- import { useCallback } from "react";
7
+ import { useCallback, useMemo } from "react";
8
8
  import {
9
9
  type BrowsedList,
10
10
  locationOpensDetail,
@@ -19,13 +19,16 @@ export type { BrowsedList };
19
19
  *
20
20
  * Every surface a list can open — compose, a reply, a conversation — reads the
21
21
  * same answer, so the four lists cannot disagree about which one is underneath.
22
+ *
23
+ * The same object until the address moves, so a caller can memoize on it.
22
24
  */
23
25
  export function useBrowsedList(): BrowsedList {
24
26
  const list = useRouterState({
25
27
  select: (state) => mailListRoute(state.matches)?.list,
26
28
  });
27
29
  const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
28
- return { list, mailboxId: mailbox?.mailboxId };
30
+ const mailboxId = mailbox?.mailboxId;
31
+ return useMemo(() => ({ list, mailboxId }), [list, mailboxId]);
29
32
  }
30
33
 
31
34
  /**