@remit/web-client 0.0.179 → 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.
- package/package.json +1 -1
- package/src/hooks/search-mirror-convergence.render.test.ts +197 -0
- package/src/hooks/search-mirror-detail.render.test.ts +4 -7
- package/src/hooks/useSearchField.render.test.ts +216 -0
- package/src/hooks/useSearchField.ts +85 -0
- package/src/hooks/useSearchMirror.ts +23 -10
- package/src/lib/mail-route.test.ts +69 -48
- package/src/lib/mail-route.ts +35 -13
- package/src/lib/search-view.test.ts +38 -27
- package/src/lib/search-view.ts +21 -2
- package/src/routes/mail.tsx +10 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.180",
|
|
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,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
|
|
201
|
-
* test are the ones path segments produce rather than ids written out
|
|
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
|
|
2
|
-
import { useEffect
|
|
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
|
|
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
|
|
85
|
+
urlQuery,
|
|
74
86
|
pathname,
|
|
75
87
|
listPath,
|
|
76
88
|
});
|
|
77
89
|
if (!mayWrite) return;
|
|
78
90
|
const queryGoesActive =
|
|
79
|
-
Boolean(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,
|
|
@@ -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
|
|
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 "
|
|
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 = [
|
|
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(
|
|
95
|
-
mailViewKey(
|
|
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(
|
|
108
|
+
assert.equal(mailViewKey("/onboarding"), "");
|
|
101
109
|
});
|
|
102
110
|
|
|
103
|
-
it("is empty on
|
|
104
|
-
assert.equal(mailViewKey(
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
123
|
-
mailViewKey(
|
|
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(
|
|
128
|
-
mailViewKey(
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
160
|
+
it("ignores a query string and a fragment", () => {
|
|
163
161
|
assert.equal(
|
|
164
|
-
mailViewKey(
|
|
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
|
+
});
|
package/src/lib/mail-route.ts
CHANGED
|
@@ -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(
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
306
|
+
const mailbox = mailViewKey("/mail/inbox-1");
|
|
296
307
|
let shell = typing(
|
|
297
308
|
{ ...searching, field: "", debounced: "", url: "" },
|
|
298
309
|
"i",
|
package/src/lib/search-view.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
+
typedInView: string,
|
|
21
31
|
viewKey: string,
|
|
22
32
|
urlQuery: string,
|
|
23
33
|
): string | undefined {
|
|
24
|
-
if (
|
|
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,
|
package/src/routes/mail.tsx
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
252
|
+
onSearchChange: setSearchInput,
|
|
287
253
|
onSearchClear: handleSearchClear,
|
|
288
254
|
onSearchClearQuery: handleSearchClearQuery,
|
|
289
255
|
intelligenceOpen,
|