@remit/web-client 0.0.143 → 0.0.145

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +79 -3
  3. package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
  4. package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
  5. package/src/components/layout/ComposeFab.tsx +11 -36
  6. package/src/components/layout/MailShell.tsx +98 -0
  7. package/src/components/layout/MailTopBar.tsx +6 -3
  8. package/src/components/mail/BriefPane.tsx +5 -5
  9. package/src/components/mail/DraftsView.tsx +0 -11
  10. package/src/components/mail/FlaggedPane.tsx +1 -1
  11. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  12. package/src/components/mail/MailboxPane.tsx +15 -5
  13. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  14. package/src/hooks/useComposeTargetMailbox.ts +75 -0
  15. package/src/hooks/useSearchMirror.ts +93 -0
  16. package/src/hooks/useSearchScope.ts +1 -1
  17. package/src/lib/compose-routes.test.ts +3 -1
  18. package/src/lib/compose-routes.ts +6 -6
  19. package/src/lib/mail-route.test.ts +157 -57
  20. package/src/lib/mail-route.ts +71 -38
  21. package/src/lib/mail-search.ts +51 -0
  22. package/src/lib/route-search-query.test.ts +50 -28
  23. package/src/lib/search-scope.ts +11 -19
  24. package/src/lib/search-view.test.ts +142 -38
  25. package/src/lib/search-view.ts +35 -11
  26. package/src/routeTree.gen.ts +155 -18
  27. package/src/router.tsx +17 -0
  28. package/src/routes/index.tsx +1 -1
  29. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  30. package/src/routes/mail/$mailboxId.tsx +27 -19
  31. package/src/routes/mail/brief/index.tsx +14 -0
  32. package/src/routes/mail/brief.tsx +50 -0
  33. package/src/routes/mail/flagged/index.tsx +10 -0
  34. package/src/routes/mail/flagged.tsx +25 -13
  35. package/src/routes/mail/index.tsx +9 -36
  36. package/src/routes/mail/outbox/index.tsx +10 -0
  37. package/src/routes/mail/outbox.tsx +23 -13
  38. package/src/routes/mail.tsx +51 -249
  39. package/src/test-support/list-search-binding.ts +34 -0
  40. package/src/hooks/useComposeTarget.ts +0 -92
@@ -1,61 +1,94 @@
1
1
  /**
2
- * Route detection for the /mail shell.
2
+ * Which list the router is showing, and whether a location still sits on one.
3
3
  *
4
- * `mail.tsx` mounts the right pane (brief, mailbox, or outbox) into
5
- * `AppShellSlotted` by inspecting the router's matched routes. The parent
6
- * `/mail` layout route is matched on EVERY child route, and its matched
7
- * `pathname` is "/mail" everywhere so detection MUST key off each leaf
8
- * route's own `routeId`, never the parent pathname. Keying off "/mail"
9
- * pathname routed every mailbox through the brief pane, which renders the
10
- * unified DailyBrief instead of the mailbox MessageList — so the message
11
- * rows (the `a[href*=selectedMessageId]` anchors) vanished. These pure
12
- * predicates pin that contract.
4
+ * Every mail URL is a list plus, below it, whatever that list has open. The
5
+ * list is a layout route, so it is named by a matched route id: the parent
6
+ * `/mail` match carries the pathname "/mail" on every child, and the deepest
7
+ * match is the thread. A thread opened from the brief and the brief itself
8
+ * resolve to the same list.
13
9
  */
14
10
 
15
- /** A matched route, minimal shape needed for pane detection. */
11
+ /** A matched route, minimal shape needed to identify the list. */
16
12
  export interface MailRouteMatch {
17
13
  routeId: string;
18
14
  params?: Record<string, string | undefined>;
19
15
  }
20
16
 
21
- /** The leaf route ids the /mail shell branches on. */
22
- export const MAIL_BRIEF_ROUTE_ID = "/mail/" as const;
17
+ /** The list layout route ids. */
18
+ export const MAIL_BRIEF_ROUTE_ID = "/mail/brief" as const;
23
19
  export const MAIL_MAILBOX_ROUTE_ID = "/mail/$mailboxId" as const;
24
20
  export const MAIL_OUTBOX_ROUTE_ID = "/mail/outbox" as const;
25
21
  export const MAIL_FLAGGED_ROUTE_ID = "/mail/flagged" as const;
26
22
 
27
- /** True only on the brief index route (/mail/), never on a mailbox/outbox. */
28
- export function isBriefRoute(matches: readonly MailRouteMatch[]): boolean {
29
- return matches.some((m) => m.routeId === MAIL_BRIEF_ROUTE_ID);
30
- }
23
+ /**
24
+ * The list a location is browsing. A mailbox route carries its id, which is
25
+ * absent only in the frame before the router has resolved the param.
26
+ */
27
+ export type MailListRoute =
28
+ | { list: "brief" }
29
+ | { list: "flagged" }
30
+ | { list: "outbox" }
31
+ | { list: "mailbox"; mailboxId: string | undefined };
31
32
 
32
- /** True only on the flagged virtual-mailbox route (/mail/flagged). */
33
- export function isFlaggedRoute(matches: readonly MailRouteMatch[]): boolean {
34
- return matches.some((m) => m.routeId === MAIL_FLAGGED_ROUTE_ID);
33
+ /**
34
+ * The list layout among the matched routes, or `undefined` outside the mail
35
+ * shell.
36
+ *
37
+ * Reads the shallowest list match, so anything mounted under a list — the open
38
+ * thread, the message, the compose surface — leaves the answer alone.
39
+ */
40
+ export function mailListRoute(
41
+ matches: readonly MailRouteMatch[],
42
+ ): MailListRoute | undefined {
43
+ for (const match of matches) {
44
+ if (match.routeId === MAIL_BRIEF_ROUTE_ID) return { list: "brief" };
45
+ if (match.routeId === MAIL_FLAGGED_ROUTE_ID) return { list: "flagged" };
46
+ if (match.routeId === MAIL_OUTBOX_ROUTE_ID) return { list: "outbox" };
47
+ if (match.routeId === MAIL_MAILBOX_ROUTE_ID)
48
+ return { list: "mailbox", mailboxId: match.params?.mailboxId };
49
+ }
50
+ return undefined;
35
51
  }
36
52
 
37
- /** True only on the outbox route. */
38
- export function isOutboxRoute(matches: readonly MailRouteMatch[]): boolean {
39
- return matches.some((m) => m.routeId === MAIL_OUTBOX_ROUTE_ID);
53
+ /**
54
+ * Identity of the list view the shell is showing — one mailbox, the brief, the
55
+ * flagged list, or the outbox. Opening a thread is not a view change, so the
56
+ * key of a list and of anything nested under it are equal; `lib/search-view.ts`
57
+ * re-seeds the search field whenever this changes, and a key that moved when a
58
+ * message opened would wipe the query the reader had just typed.
59
+ */
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
+ }
40
73
  }
41
74
 
42
- /** True only on a mailbox route (/mail/$mailboxId). */
43
- export function isMailboxRoute(matches: readonly MailRouteMatch[]): boolean {
44
- return matches.some((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID);
75
+ /** One mailbox's view key. Two mailboxes are two views. */
76
+ export function mailboxViewKey(mailboxId: string): string {
77
+ return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
45
78
  }
46
79
 
47
80
  /**
48
- * Identity of the list view the shell is showing one mailbox, the brief, the
49
- * flagged list, or the outbox. Two locations share a key when they differ only
50
- * in search params (opening a result, mirroring `q`), so opening a hit is not a
51
- * view change while switching mailbox is. `lib/search-view.ts` re-seeds the
52
- * search field whenever this changes.
81
+ * Whether a location sits on a list, or on something the list has open below it.
82
+ *
83
+ * Answers off the pathname rather than the matches on purpose. The router
84
+ * commits the new location before it swaps the matches, so a component leaving
85
+ * the screen sees its own matches under the destination's address for as long as
86
+ * the destination takes to mount. `pathname` is what says where the reader is
87
+ * going; `matches` is what says where they still are.
88
+ *
89
+ * `listPath` is a whole segment: a folder named `briefing` is not the brief.
53
90
  */
54
- export function mailViewKey(matches: readonly MailRouteMatch[]): string {
55
- const mailboxId = matches.find((m) => m.params?.mailboxId)?.params?.mailboxId;
56
- if (mailboxId) return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
57
- if (isFlaggedRoute(matches)) return MAIL_FLAGGED_ROUTE_ID;
58
- if (isOutboxRoute(matches)) return MAIL_OUTBOX_ROUTE_ID;
59
- if (isBriefRoute(matches)) return MAIL_BRIEF_ROUTE_ID;
60
- return "";
91
+ export function locationIsOnList(pathname: string, listPath: string): boolean {
92
+ if (pathname === listPath) return true;
93
+ return pathname.startsWith(`${listPath}/`);
61
94
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The search params each mail list validates.
3
+ *
4
+ * `q` lives on the parent `/mail` route, but a child's `validateSearch` is
5
+ * authoritative for its own URL, so a child that omits `q` strips it: typing a
6
+ * query on that list does nothing and the query is lost on the next navigation.
7
+ * Every list extends the same base rather than re-declaring the param, so there
8
+ * is one place for it to be right.
9
+ */
10
+ import { z } from "zod";
11
+
12
+ /** What every list carries, whatever else it carries. */
13
+ const listSearch = z.object({ q: z.string().optional() });
14
+
15
+ export const briefSearchSchema = listSearch.extend({
16
+ selectedMessageId: z.string().optional(),
17
+ // A tapped semantic "Related" hit can point at a message outside the loaded
18
+ // brief list; carrying its thread + mailbox lets the brief open it directly.
19
+ selectedThreadId: z.string().optional(),
20
+ selectedMailboxId: z.string().optional(),
21
+ });
22
+
23
+ export const flaggedSearchSchema = listSearch.extend({
24
+ selectedMessageId: z.string().optional(),
25
+ });
26
+
27
+ export const outboxSearchSchema = listSearch.extend({
28
+ selectedOutboxMessageId: z.string().optional(),
29
+ });
30
+
31
+ export const mailboxSearchSchema = listSearch.extend({
32
+ selectedMessageId: z.string().optional(),
33
+ // A tapped semantic "Related" hit can point at a message outside the loaded
34
+ // list; carrying its thread lets the mailbox open it directly (the mailbox is
35
+ // the route param). See `buildConversationTarget`.
36
+ selectedThreadId: z.string().optional(),
37
+ });
38
+
39
+ /**
40
+ * `/mail` itself only redirects to the brief, so it accepts what the brief
41
+ * accepts and hands it straight on.
42
+ */
43
+ export const mailIndexSearchSchema = briefSearchSchema;
44
+
45
+ /** Every list's schema, by the path it validates. */
46
+ export const mailListSearchSchemas = {
47
+ "/mail/brief": briefSearchSchema,
48
+ "/mail/flagged": flaggedSearchSchema,
49
+ "/mail/outbox": outboxSearchSchema,
50
+ "/mail/$mailboxId": mailboxSearchSchema,
51
+ } as const;
@@ -1,38 +1,29 @@
1
1
  /**
2
- * `q` lives on the parent `/mail` route, but every child re-declares it: a
3
- * child's `validateSearch` is authoritative for its own URL, so a child that
4
- * omits `q` strips it. The top bar mounts a search field on all four of these
5
- * routes, so a stripped `q` means typing a query does nothing and the query is
6
- * lost on the next navigation.
2
+ * `q` lives on the parent `/mail` route, but every list re-declares it: a
3
+ * child's `validateSearch` is authoritative for its own URL, so a list that
4
+ * omits `q` strips it. The top bar mounts a search field on all four lists, so
5
+ * a stripped `q` means typing a query does nothing and the query is lost on the
6
+ * next navigation.
7
+ *
8
+ * Two halves: the schemas carry `q`, and each route is wired to the schema
9
+ * declared for its own path. The second half has to load route files, so it runs
10
+ * out of process — see `test-support/list-search-binding.ts`.
7
11
  */
8
12
  import assert from "node:assert/strict";
13
+ import { execFileSync } from "node:child_process";
9
14
  import { describe, it } from "node:test";
10
- import { z } from "zod";
11
- import { Route as MailboxRoute } from "../routes/mail/$mailboxId";
12
- import { Route as FlaggedRoute } from "../routes/mail/flagged";
13
- import { Route as BriefRoute } from "../routes/mail/index";
14
- import { Route as OutboxRoute } from "../routes/mail/outbox";
15
+ import { fileURLToPath } from "node:url";
16
+ import { mailIndexSearchSchema, mailListSearchSchemas } from "./mail-search.js";
15
17
 
16
- const routes = {
17
- "/mail/ (daily brief)": BriefRoute,
18
- "/mail/flagged": FlaggedRoute,
19
- "/mail/outbox": OutboxRoute,
20
- "/mail/$mailboxId": MailboxRoute,
18
+ const schemas = {
19
+ ...mailListSearchSchemas,
20
+ "/mail/ (the redirect to the brief)": mailIndexSearchSchema,
21
21
  };
22
22
 
23
- const parse = (route: { options: { validateSearch?: unknown } }) => {
24
- const schema = route.options.validateSearch;
25
- assert.ok(
26
- schema instanceof z.ZodType,
27
- "route must validate its search with a zod schema",
28
- );
29
- return (search: Record<string, unknown>) => schema.parse(search);
30
- };
31
-
32
- describe("every /mail child route carries `q` through its own validation", () => {
33
- for (const [name, route] of Object.entries(routes)) {
23
+ describe("every /mail list carries `q` through its own validation", () => {
24
+ for (const [name, schema] of Object.entries(schemas)) {
34
25
  it(`${name} preserves a query`, () => {
35
- const parsed = parse(route)({ q: "invoice" }) as { q?: string };
26
+ const parsed = schema.parse({ q: "invoice" }) as { q?: string };
36
27
  assert.equal(
37
28
  parsed.q,
38
29
  "invoice",
@@ -41,8 +32,39 @@ describe("every /mail child route carries `q` through its own validation", () =>
41
32
  });
42
33
 
43
34
  it(`${name} leaves q absent when there is none`, () => {
44
- const parsed = parse(route)({}) as { q?: string };
35
+ const parsed = schema.parse({}) as { q?: string };
45
36
  assert.equal(parsed.q, undefined);
46
37
  });
47
38
  }
48
39
  });
40
+
41
+ describe("every /mail list route uses the schema declared for its path", () => {
42
+ it("holds for all four lists", () => {
43
+ const check = fileURLToPath(
44
+ new URL("../test-support/list-search-binding.ts", import.meta.url),
45
+ );
46
+ const register = fileURLToPath(
47
+ new URL("../../test-support/register.mjs", import.meta.url),
48
+ );
49
+ // The runner re-injects its coverage directory into any child, even one
50
+ // given an environment without it, and coverage from this child would put
51
+ // every pane the route files reach into the denominator — the reason the
52
+ // check is out of process at all. An empty value is the one thing that
53
+ // stops the child writing there.
54
+ assert.doesNotThrow(() =>
55
+ execFileSync(
56
+ process.execPath,
57
+ ["--import", "tsx", "--import", register, check],
58
+ {
59
+ stdio: "pipe",
60
+ encoding: "utf8",
61
+ env: {
62
+ ...process.env,
63
+ NODE_V8_COVERAGE: "",
64
+ TSX_TSCONFIG_PATH: "./tsconfig.test.json",
65
+ },
66
+ },
67
+ ),
68
+ );
69
+ });
70
+ });
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Removing the chip means "search everything", which is the daily brief — the
13
13
  * cross-account view whose scope is nothing. So removal is a navigation to
14
- * `/mail/` carrying the query, not an edit of the query text.
14
+ * `/mail/brief` carrying the query, not an edit of the query text.
15
15
  *
16
16
  * There is exactly one scope, and it is this one. A typed `in:` term is a
17
17
  * second, competing answer to "which mailbox", so it is recognized only where
@@ -22,14 +22,7 @@
22
22
  * Pure functions only. `useSearchScope` binds these to the router.
23
23
  */
24
24
  import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
25
- import {
26
- isBriefRoute,
27
- isFlaggedRoute,
28
- isMailboxRoute,
29
- isOutboxRoute,
30
- MAIL_MAILBOX_ROUTE_ID,
31
- type MailRouteMatch,
32
- } from "./mail-route";
25
+ import { type MailRouteMatch, mailListRoute } from "./mail-route";
33
26
 
34
27
  /**
35
28
  * Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
@@ -72,9 +65,8 @@ export function scopeLabelForMailboxName(name: string): string {
72
65
  * not.
73
66
  */
74
67
  export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
75
- return (
76
- isFlaggedRoute(matches) || isOutboxRoute(matches) || isMailboxRoute(matches)
77
- );
68
+ const route = mailListRoute(matches);
69
+ return route !== undefined && route.list !== "brief";
78
70
  }
79
71
 
80
72
  /**
@@ -83,8 +75,8 @@ export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
83
75
  export function routeMailboxId(
84
76
  matches: readonly MailRouteMatch[],
85
77
  ): string | undefined {
86
- return matches.find((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID)?.params
87
- ?.mailboxId;
78
+ const route = mailListRoute(matches);
79
+ return route?.list === "mailbox" ? route.mailboxId : undefined;
88
80
  }
89
81
 
90
82
  /**
@@ -128,20 +120,20 @@ export function searchScopeForRoute(
128
120
  matches: readonly MailRouteMatch[],
129
121
  mailboxName?: string | null,
130
122
  ): SearchScopeState {
131
- if (isBriefRoute(matches)) return { kind: "global" };
132
- if (isFlaggedRoute(matches)) {
123
+ const route = mailListRoute(matches);
124
+ if (route?.list === "flagged") {
133
125
  return {
134
126
  kind: "scoped",
135
127
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
136
128
  };
137
129
  }
138
- if (isOutboxRoute(matches)) {
130
+ if (route?.list === "outbox") {
139
131
  return {
140
132
  kind: "scoped",
141
133
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
142
134
  };
143
135
  }
144
- if (isMailboxRoute(matches)) {
136
+ if (route?.list === "mailbox") {
145
137
  if (!mailboxName) return { kind: "pending" };
146
138
  return {
147
139
  kind: "scoped",
@@ -177,6 +169,6 @@ export function resultsScopeForRoute(
177
169
  role?: FolderRole,
178
170
  ): ResultsScope {
179
171
  if (state.kind === "global") return { kind: "global" };
180
- if (isFlaggedRoute(matches)) return { kind: "collection" };
172
+ if (mailListRoute(matches)?.list === "flagged") return { kind: "collection" };
181
173
  return { kind: "folder", ...(role ? { role } : {}) };
182
174
  }
@@ -6,7 +6,11 @@
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { describe, it } from "node:test";
9
- import { type MailRouteMatch, mailViewKey } from "./mail-route.js";
9
+ import {
10
+ MAIL_BRIEF_ROUTE_ID,
11
+ type MailRouteMatch,
12
+ mailViewKey,
13
+ } from "./mail-route.js";
10
14
  import {
11
15
  committedSearchQuery,
12
16
  searchInputForView,
@@ -19,31 +23,6 @@ const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
19
23
  { routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
20
24
  ];
21
25
 
22
- describe("mailViewKey", () => {
23
- it("distinguishes two mailboxes", () => {
24
- assert.notEqual(
25
- mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
26
- mailViewKey(matches("/mail/$mailboxId", "archive-1")),
27
- );
28
- });
29
-
30
- it("gives the same mailbox one key regardless of search params", () => {
31
- assert.equal(
32
- mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
33
- mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
34
- );
35
- });
36
-
37
- it("separates the brief, flagged and outbox views", () => {
38
- const keys = [
39
- mailViewKey(matches("/mail/")),
40
- mailViewKey(matches("/mail/flagged")),
41
- mailViewKey(matches("/mail/outbox")),
42
- ];
43
- assert.equal(new Set(keys).size, 3);
44
- });
45
- });
46
-
47
26
  describe("searchInputForView", () => {
48
27
  it("clears the field when the destination carries no query", () => {
49
28
  assert.equal(
@@ -71,7 +50,11 @@ describe("searchInputForView", () => {
71
50
 
72
51
  it("seeds from the destination's own query (deep link, saved search)", () => {
73
52
  assert.equal(
74
- searchInputForView("/mail/$mailboxId:inbox-1", "/mail/", "invoice"),
53
+ searchInputForView(
54
+ "/mail/$mailboxId:inbox-1",
55
+ MAIL_BRIEF_ROUTE_ID,
56
+ "invoice",
57
+ ),
75
58
  "invoice",
76
59
  );
77
60
  });
@@ -89,12 +72,24 @@ describe("committedSearchQuery", () => {
89
72
  });
90
73
 
91
74
  describe("shouldMirrorQuery", () => {
75
+ /** A settled query on the brief, with the reader still on it. */
76
+ const onTheBrief = {
77
+ searchInput: "invoice",
78
+ committedQuery: "invoice",
79
+ urlQuery: "",
80
+ pathname: "/mail/brief",
81
+ listPath: "/mail/brief",
82
+ };
83
+
92
84
  it("writes a settled query the URL does not have yet", () => {
93
- assert.equal(shouldMirrorQuery("invoice", "invoice", ""), true);
85
+ assert.equal(shouldMirrorQuery(onTheBrief), true);
94
86
  });
95
87
 
96
88
  it("stays quiet once the URL already says it", () => {
97
- assert.equal(shouldMirrorQuery("invoice", "invoice", "invoice"), false);
89
+ assert.equal(
90
+ shouldMirrorQuery({ ...onTheBrief, urlQuery: "invoice" }),
91
+ false,
92
+ );
98
93
  });
99
94
 
100
95
  it("does not strip the query a deep link just arrived with", () => {
@@ -102,11 +97,71 @@ describe("shouldMirrorQuery", () => {
102
97
  // the committed query is still the previous view's. Writing it would drop
103
98
  // `q` for the length of the debounce — and with it the search the link
104
99
  // asked for.
105
- assert.equal(shouldMirrorQuery("invoice", "", "invoice"), false);
100
+ assert.equal(
101
+ shouldMirrorQuery({
102
+ ...onTheBrief,
103
+ committedQuery: "",
104
+ urlQuery: "invoice",
105
+ }),
106
+ false,
107
+ );
106
108
  });
107
109
 
108
110
  it("waits for the debounce while the user is still typing", () => {
109
- assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
111
+ assert.equal(
112
+ shouldMirrorQuery({
113
+ ...onTheBrief,
114
+ searchInput: "invoi",
115
+ committedQuery: "inv",
116
+ }),
117
+ false,
118
+ );
119
+ });
120
+
121
+ // The list the reader is leaving is still mounted, and its debounce can settle
122
+ // under the address of the list they are going to. Writing then navigates back
123
+ // to this list and replaces the entry they just pushed: they click Inbox and
124
+ // land on the brief carrying the half-typed query.
125
+ it("does not write from the list the reader has just left", () => {
126
+ assert.equal(
127
+ shouldMirrorQuery({ ...onTheBrief, pathname: "/mail/9f1c-abc" }),
128
+ false,
129
+ );
130
+ assert.equal(
131
+ shouldMirrorQuery({
132
+ ...onTheBrief,
133
+ pathname: "/mail/flagged",
134
+ }),
135
+ false,
136
+ );
137
+ assert.equal(
138
+ shouldMirrorQuery({ ...onTheBrief, pathname: "/settings/accounts" }),
139
+ false,
140
+ );
141
+ });
142
+
143
+ it("writes from a mailbox only while that mailbox is the one addressed", () => {
144
+ const onInbox = {
145
+ ...onTheBrief,
146
+ pathname: "/mail/inbox-1",
147
+ listPath: "/mail/inbox-1",
148
+ };
149
+ assert.equal(shouldMirrorQuery(onInbox), true);
150
+ assert.equal(
151
+ shouldMirrorQuery({ ...onInbox, pathname: "/mail/archive-1" }),
152
+ false,
153
+ );
154
+ });
155
+
156
+ it("still writes with a thread open below the list", () => {
157
+ // A thread is the list's own child route, so the reader has not left.
158
+ assert.equal(
159
+ shouldMirrorQuery({
160
+ ...onTheBrief,
161
+ pathname: "/mail/brief/thread-1/message-1",
162
+ }),
163
+ true,
164
+ );
110
165
  });
111
166
  });
112
167
 
@@ -119,24 +174,42 @@ describe("shouldMirrorQuery", () => {
119
174
  */
120
175
  interface Shell {
121
176
  viewKey: string;
177
+ /** The path of the list whose mirror is running. */
178
+ listPath: string;
122
179
  field: string;
123
180
  debounced: string;
124
181
  url: string;
125
182
  }
126
183
 
127
184
  /** One render of the shell for a location, returning the state it settles on. */
128
- const render = (shell: Shell, viewKey: string, url: string): Shell => {
185
+ const render = (
186
+ shell: Shell,
187
+ viewKey: string,
188
+ url: string,
189
+ listPath = shell.listPath,
190
+ ): Shell => {
129
191
  const field =
130
192
  viewKey === shell.viewKey
131
193
  ? shell.field
132
194
  : (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
133
- return { ...shell, viewKey, field, url };
195
+ return { ...shell, viewKey, listPath, field, url };
134
196
  };
135
197
 
136
- /** What the mirror effect would do after that render. */
137
- const mirror = (shell: Shell): Shell => {
198
+ /**
199
+ * What the mirror effect would do after that render. `pathname` is where the
200
+ * router says the reader is, which is the destination from the moment they
201
+ * navigate — before the list they are leaving has stopped running effects.
202
+ */
203
+ const mirror = (shell: Shell, pathname = shell.listPath): Shell => {
138
204
  const committed = committedSearchQuery(shell.field, shell.debounced);
139
- if (!shouldMirrorQuery(shell.field, committed, shell.url)) return shell;
205
+ const mayWrite = shouldMirrorQuery({
206
+ searchInput: shell.field,
207
+ committedQuery: committed,
208
+ urlQuery: shell.url,
209
+ pathname,
210
+ listPath: shell.listPath,
211
+ });
212
+ if (!mayWrite) return shell;
140
213
  return { ...shell, url: committed };
141
214
  };
142
215
 
@@ -149,6 +222,7 @@ const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
149
222
  describe("search across a view change", () => {
150
223
  const searching: Shell = {
151
224
  viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
225
+ listPath: "/mail/inbox-1",
152
226
  field: "invoice",
153
227
  debounced: "invoice",
154
228
  url: "invoice",
@@ -157,12 +231,36 @@ describe("search across a view change", () => {
157
231
  it("ends the search when the user leaves the view", () => {
158
232
  // The nav link drops `q`, so the destination carries none.
159
233
  const next = mirror(
160
- render(searching, mailViewKey(matches("/mail/$mailboxId", "sent-1")), ""),
234
+ render(
235
+ searching,
236
+ mailViewKey(matches("/mail/$mailboxId", "sent-1")),
237
+ "",
238
+ "/mail/sent-1",
239
+ ),
161
240
  );
162
241
  assert.equal(next.field, "");
163
242
  assert.equal(next.url, "");
164
243
  });
165
244
 
245
+ // The whole failure: the reader types on the brief and clicks Inbox inside the
246
+ // debounce. The brief is still mounted, its debounce settles, and its mirror
247
+ // would navigate to the brief — superseding the load and replacing the entry
248
+ // the reader had just pushed, so Inbox never arrives.
249
+ it("does not navigate back to the list the reader is leaving", () => {
250
+ const brief: Shell = {
251
+ viewKey: mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
252
+ listPath: MAIL_BRIEF_ROUTE_ID,
253
+ field: "inv",
254
+ debounced: "",
255
+ url: "",
256
+ };
257
+ // The debounce catches up while the inbox is still loading.
258
+ const leaving = settle(brief);
259
+ assert.equal(mirror(leaving, "/mail/9f1c-abc").url, "");
260
+ // On the brief itself the same settled query is written as before.
261
+ assert.equal(mirror(leaving).url, "inv");
262
+ });
263
+
166
264
  it("does not put the query it just left onto the view it landed on", () => {
167
265
  // The debounce still holds "invoice" for up to 200ms after the move. The
168
266
  // mirror must not write it back — that is #47 returning by another route.
@@ -170,6 +268,7 @@ describe("search across a view change", () => {
170
268
  searching,
171
269
  mailViewKey(matches("/mail/$mailboxId", "sent-1")),
172
270
  "",
271
+ "/mail/sent-1",
173
272
  );
174
273
  assert.equal(landed.debounced, "invoice");
175
274
  assert.equal(mirror(landed).url, "");
@@ -179,7 +278,12 @@ describe("search across a view change", () => {
179
278
  // Dropping the scope chip navigates to the brief carrying the query, so
180
279
  // the same words are searched with a wider scope.
181
280
  const next = mirror(
182
- render(searching, mailViewKey(matches("/mail/")), "invoice"),
281
+ render(
282
+ searching,
283
+ mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
284
+ "invoice",
285
+ MAIL_BRIEF_ROUTE_ID,
286
+ ),
183
287
  );
184
288
  assert.equal(next.field, "invoice");
185
289
  assert.equal(next.url, "invoice");