@carlonicora/nextjs-jsonapi 2.1.2 → 2.2.0
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/dist/{BlockNoteEditor-FYO6TV4X.js → BlockNoteEditor-5TMQ5YCD.js} +9 -9
- package/dist/{BlockNoteEditor-FYO6TV4X.js.map → BlockNoteEditor-5TMQ5YCD.js.map} +1 -1
- package/dist/{BlockNoteEditor-DYQ5BLQI.mjs → BlockNoteEditor-HAUE4EPR.mjs} +2 -2
- package/dist/billing/index.js +310 -310
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-TEJDX7X7.js → chunk-HPKCZMQX.js} +96 -31
- package/dist/chunk-HPKCZMQX.js.map +1 -0
- package/dist/{chunk-B3ELVSTL.mjs → chunk-PGW2OP3L.mjs} +139 -74
- package/dist/chunk-PGW2OP3L.mjs.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +12 -0
- package/dist/components/index.d.ts +12 -0
- package/dist/components/index.js +2 -2
- package/dist/components/index.mjs +1 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/help/index.js +31 -31
- package/dist/features/help/index.mjs +1 -1
- package/dist/features/tokenusage/index.js +56 -56
- package/dist/features/tokenusage/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/navigations/Header.tsx +5 -2
- package/src/components/navigations/RecentPagesNavigator.tsx +64 -33
- package/src/components/navigations/__tests__/RecentPagesNavigator.spec.tsx +166 -0
- package/src/hooks/__tests__/usePageTracker.spec.tsx +296 -0
- package/src/hooks/usePageTracker.ts +108 -19
- package/dist/chunk-B3ELVSTL.mjs.map +0 -1
- package/dist/chunk-TEJDX7X7.js.map +0 -1
- /package/dist/{BlockNoteEditor-DYQ5BLQI.mjs.map → BlockNoteEditor-HAUE4EPR.mjs.map} +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { createStore, Provider } from "jotai";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { recentPagesAtom, type RecentPage } from "../../atoms";
|
|
5
|
+
import { usePageTracker } from "../usePageTracker";
|
|
6
|
+
|
|
7
|
+
// usePathname is globally mocked to "/" in vitest.setup.ts. Override it here so
|
|
8
|
+
// navigations can be simulated. A spec-level vi.mock takes precedence.
|
|
9
|
+
//
|
|
10
|
+
// The hook's stale-observer guard reads the REAL browser location
|
|
11
|
+
// (window.location.pathname), not this mock, so every write to
|
|
12
|
+
// pathnameRef.current also pushes the browser history entry. Individual tests
|
|
13
|
+
// still just say `pathnameRef.current = "..."` — this only keeps the two in
|
|
14
|
+
// sync underneath that.
|
|
15
|
+
let _pathname = "/";
|
|
16
|
+
const pathnameRef = {
|
|
17
|
+
get current() {
|
|
18
|
+
return _pathname;
|
|
19
|
+
},
|
|
20
|
+
set current(value: string) {
|
|
21
|
+
_pathname = value;
|
|
22
|
+
window.history.pushState({}, "", value);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
vi.mock("next/navigation", () => ({
|
|
26
|
+
usePathname: () => pathnameRef.current,
|
|
27
|
+
useRouter: () => ({
|
|
28
|
+
push: vi.fn(),
|
|
29
|
+
replace: vi.fn(),
|
|
30
|
+
prefetch: vi.fn(),
|
|
31
|
+
back: vi.fn(),
|
|
32
|
+
forward: vi.fn(),
|
|
33
|
+
refresh: vi.fn(),
|
|
34
|
+
}),
|
|
35
|
+
useSearchParams: () => new URLSearchParams(),
|
|
36
|
+
useParams: () => ({}),
|
|
37
|
+
redirect: vi.fn(),
|
|
38
|
+
notFound: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock("../../client/config", () => ({
|
|
42
|
+
getTrackablePages: () => [
|
|
43
|
+
{ pageUrl: "/npcs", name: "npcs" },
|
|
44
|
+
{ pageUrl: "/campaigns", name: "campaigns" },
|
|
45
|
+
],
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
function Harness() {
|
|
49
|
+
usePageTracker();
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mount(store: ReturnType<typeof createStore>) {
|
|
54
|
+
return render(
|
|
55
|
+
<Provider store={store}>
|
|
56
|
+
<Harness />
|
|
57
|
+
</Provider>,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pages(store: ReturnType<typeof createStore>): RecentPage[] {
|
|
62
|
+
return store.get(recentPagesAtom);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
pathnameRef.current = "/";
|
|
67
|
+
document.title = "Home | App";
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("usePageTracker", () => {
|
|
71
|
+
it("records the entity name on a hard load, where the title is already ours", async () => {
|
|
72
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
73
|
+
document.title = "[NPC] Charlie | App";
|
|
74
|
+
const store = createStore();
|
|
75
|
+
store.set(recentPagesAtom, []);
|
|
76
|
+
|
|
77
|
+
mount(store);
|
|
78
|
+
|
|
79
|
+
await waitFor(() => expect(pages(store)).toHaveLength(1));
|
|
80
|
+
expect(pages(store)[0]).toMatchObject({ url: "/npcs/npc-1", title: "Charlie", moduleType: "npcs" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("never stamps an entity with the previous page's name, then upgrades when the title lands", async () => {
|
|
84
|
+
// The defect this fixes: the route commits before its streamed metadata, so
|
|
85
|
+
// document.title still belongs to the page we came from.
|
|
86
|
+
pathnameRef.current = "/campaigns/camp-1";
|
|
87
|
+
document.title = "[Campaign] Venezia Obscura | App";
|
|
88
|
+
const store = createStore();
|
|
89
|
+
store.set(recentPagesAtom, []);
|
|
90
|
+
const view = mount(store);
|
|
91
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
|
|
92
|
+
|
|
93
|
+
// Client-side navigate to an NPC. The title has NOT changed yet.
|
|
94
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
95
|
+
view.rerender(
|
|
96
|
+
<Provider store={store}>
|
|
97
|
+
<Harness />
|
|
98
|
+
</Provider>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await waitFor(() => expect(pages(store)[0]?.url).toBe("/npcs/npc-1"));
|
|
102
|
+
expect(pages(store)[0].title).toBe("npcs");
|
|
103
|
+
expect(pages(store)[0].title).not.toBe("Venezia Obscura");
|
|
104
|
+
|
|
105
|
+
// Now the route's own metadata streams in.
|
|
106
|
+
document.title = "[NPC] Charlie | App";
|
|
107
|
+
|
|
108
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Charlie"));
|
|
109
|
+
expect(pages(store)).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("records the entity name immediately when the title arrives in the same commit as the navigation", async () => {
|
|
113
|
+
// The prefetched-production ordering: the link's payload (including the new
|
|
114
|
+
// <title>) is already cached, so React updates the route AND the title in
|
|
115
|
+
// the same commit. There is no intermediate tick where the NPC's route is
|
|
116
|
+
// live but document.title still reads the campaign's — which is the signal
|
|
117
|
+
// the "seed with the module name, let the observer correct it" path above
|
|
118
|
+
// depends on. This is the exact scenario an earlier version of this test
|
|
119
|
+
// suite was written to assert and then reordered away from without a guard
|
|
120
|
+
// in place: it described a real case the code did not yet handle.
|
|
121
|
+
pathnameRef.current = "/campaigns/camp-1";
|
|
122
|
+
document.title = "[Campaign] Venezia Obscura | App";
|
|
123
|
+
const store = createStore();
|
|
124
|
+
store.set(recentPagesAtom, []);
|
|
125
|
+
const view = mount(store);
|
|
126
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
|
|
127
|
+
|
|
128
|
+
// Client-side navigate to an NPC. The title has ALREADY changed, before
|
|
129
|
+
// rerender() is even called.
|
|
130
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
131
|
+
document.title = "[NPC] Charlie | App";
|
|
132
|
+
view.rerender(
|
|
133
|
+
<Provider store={store}>
|
|
134
|
+
<Harness />
|
|
135
|
+
</Provider>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await waitFor(() => expect(pages(store)[0]?.url).toBe("/npcs/npc-1"));
|
|
139
|
+
expect(pages(store)[0].title).toBe("Charlie");
|
|
140
|
+
expect(pages(store)[0].title).not.toBe("npcs");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("does not let a stale observer rename the entry we left", async () => {
|
|
144
|
+
// React runs a route's effect cleanup (disconnecting its observer) as part
|
|
145
|
+
// of the SAME synchronous flush that mounts the next route's effect — so a
|
|
146
|
+
// plain `rerender()` in this test harness can never catch the campaign's
|
|
147
|
+
// observer still connected; cleanup would always have already run by the
|
|
148
|
+
// time any queued mutation callback got a chance to fire. The real bug
|
|
149
|
+
// needs the browser to have already moved on (window.location changed)
|
|
150
|
+
// while the campaign's MutationObserver is still live, i.e. a mutation
|
|
151
|
+
// that lands and is allowed to flush BEFORE React reconciles the
|
|
152
|
+
// navigation. So: change the pathname (which pushes real browser history,
|
|
153
|
+
// see pathnameRef above) and the title, then yield to the microtask queue
|
|
154
|
+
// — WITHOUT calling rerender() yet — so the still-mounted campaign
|
|
155
|
+
// effect's observer is the one that receives the mutation, exactly as it
|
|
156
|
+
// would in production between the DOM commit and the deferred passive
|
|
157
|
+
// effect cleanup.
|
|
158
|
+
pathnameRef.current = "/campaigns/camp-1";
|
|
159
|
+
document.title = "[Campaign] Venezia Obscura | App";
|
|
160
|
+
const store = createStore();
|
|
161
|
+
store.set(recentPagesAtom, []);
|
|
162
|
+
const view = mount(store);
|
|
163
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
|
|
164
|
+
|
|
165
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
166
|
+
document.title = "[NPC] Charlie | App";
|
|
167
|
+
// Let the campaign's still-connected observer see this mutation before its
|
|
168
|
+
// cleanup ever runs.
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
170
|
+
|
|
171
|
+
const campaignEntry = () => pages(store).find((page) => page.url === "/campaigns/camp-1");
|
|
172
|
+
expect(campaignEntry()?.title).toBe("Venezia Obscura");
|
|
173
|
+
|
|
174
|
+
// Now let React actually reconcile the navigation.
|
|
175
|
+
view.rerender(
|
|
176
|
+
<Provider store={store}>
|
|
177
|
+
<Harness />
|
|
178
|
+
</Provider>,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await waitFor(() => expect(pages(store)[0]?.url).toBe("/npcs/npc-1"));
|
|
182
|
+
expect(campaignEntry()?.title).toBe("Venezia Obscura");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("ignores head mutations that do not change the resolved title", async () => {
|
|
186
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
187
|
+
document.title = "[NPC] Charlie | App";
|
|
188
|
+
const store = createStore();
|
|
189
|
+
store.set(recentPagesAtom, []);
|
|
190
|
+
mount(store);
|
|
191
|
+
await waitFor(() => expect(pages(store)).toHaveLength(1));
|
|
192
|
+
const firstTimestamp = pages(store)[0].timestamp;
|
|
193
|
+
|
|
194
|
+
// Dev servers mutate <head> constantly for HMR styles.
|
|
195
|
+
const style = document.createElement("style");
|
|
196
|
+
document.head.appendChild(style);
|
|
197
|
+
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
199
|
+
expect(pages(store)).toHaveLength(1);
|
|
200
|
+
expect(pages(store)[0].timestamp).toBe(firstTimestamp);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("records nothing for a list route with no entity id", async () => {
|
|
204
|
+
pathnameRef.current = "/npcs";
|
|
205
|
+
document.title = "[NPC] npcs | App";
|
|
206
|
+
const store = createStore();
|
|
207
|
+
store.set(recentPagesAtom, []);
|
|
208
|
+
mount(store);
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
210
|
+
expect(pages(store)).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("records nothing for a module that is not trackable", async () => {
|
|
214
|
+
pathnameRef.current = "/secrets/secret-1";
|
|
215
|
+
document.title = "[Secret] Hidden | App";
|
|
216
|
+
const store = createStore();
|
|
217
|
+
store.set(recentPagesAtom, []);
|
|
218
|
+
mount(store);
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
220
|
+
expect(pages(store)).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("stops observing once unmounted", async () => {
|
|
224
|
+
pathnameRef.current = "/npcs/npc-1";
|
|
225
|
+
document.title = "[NPC] Charlie | App";
|
|
226
|
+
const store = createStore();
|
|
227
|
+
store.set(recentPagesAtom, []);
|
|
228
|
+
const view = mount(store);
|
|
229
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Charlie"));
|
|
230
|
+
|
|
231
|
+
view.unmount();
|
|
232
|
+
document.title = "[NPC] Bob | App";
|
|
233
|
+
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
235
|
+
expect(pages(store)[0].title).toBe("Charlie");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("refreshes an entity's entry from a sub-page without renaming it", async () => {
|
|
239
|
+
// Sub-pages title themselves after the section — "[Campaign] NPCs Venezia
|
|
240
|
+
// Obscura" — so they must never overwrite the entity's own name.
|
|
241
|
+
pathnameRef.current = "/campaigns/camp-1";
|
|
242
|
+
document.title = "[Campaign] Venezia Obscura | App";
|
|
243
|
+
const store = createStore();
|
|
244
|
+
store.set(recentPagesAtom, []);
|
|
245
|
+
const view = mount(store);
|
|
246
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
|
|
247
|
+
const firstTimestamp = pages(store)[0].timestamp;
|
|
248
|
+
|
|
249
|
+
pathnameRef.current = "/campaigns/camp-1//npcs";
|
|
250
|
+
document.title = "[Campaign] NPCs Venezia Obscura | App";
|
|
251
|
+
view.rerender(
|
|
252
|
+
<Provider store={store}>
|
|
253
|
+
<Harness />
|
|
254
|
+
</Provider>,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await waitFor(() => expect(pages(store)[0]?.timestamp).not.toBe(firstTimestamp));
|
|
258
|
+
expect(pages(store)[0].title).toBe("Venezia Obscura");
|
|
259
|
+
expect(pages(store)[0].url).toBe("/campaigns/camp-1");
|
|
260
|
+
expect(pages(store)).toHaveLength(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("falls back to the module name when a sub-page is the first thing recorded", async () => {
|
|
264
|
+
pathnameRef.current = "/campaigns/camp-1//npcs";
|
|
265
|
+
document.title = "[Campaign] NPCs Venezia Obscura | App";
|
|
266
|
+
const store = createStore();
|
|
267
|
+
store.set(recentPagesAtom, []);
|
|
268
|
+
mount(store);
|
|
269
|
+
|
|
270
|
+
await waitFor(() => expect(pages(store)).toHaveLength(1));
|
|
271
|
+
expect(pages(store)[0]).toMatchObject({ url: "/campaigns/camp-1", title: "campaigns" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("still upgrades the title when the entity's own page is opened later", async () => {
|
|
275
|
+
pathnameRef.current = "/campaigns/camp-1//npcs";
|
|
276
|
+
document.title = "[Campaign] NPCs Venezia Obscura | App";
|
|
277
|
+
const store = createStore();
|
|
278
|
+
store.set(recentPagesAtom, []);
|
|
279
|
+
const view = mount(store);
|
|
280
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("campaigns"));
|
|
281
|
+
|
|
282
|
+
// Navigate to the entity's own page. As in the real app, the route commits
|
|
283
|
+
// BEFORE its metadata streams in, so the title still reads the sub-page's
|
|
284
|
+
// until after the rerender — which is exactly what the observer is for.
|
|
285
|
+
pathnameRef.current = "/campaigns/camp-1";
|
|
286
|
+
view.rerender(
|
|
287
|
+
<Provider store={store}>
|
|
288
|
+
<Harness />
|
|
289
|
+
</Provider>,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
document.title = "[Campaign] Venezia Obscura | App";
|
|
293
|
+
|
|
294
|
+
await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -2,20 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
import { useAtom } from "jotai";
|
|
4
4
|
import { usePathname } from "next/navigation";
|
|
5
|
-
import { useEffect } from "react";
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
6
|
import { RecentPage, recentPagesAtom } from "../atoms";
|
|
7
7
|
import { getTrackablePages } from "../client/config";
|
|
8
8
|
|
|
9
9
|
// Routes to exclude from tracking
|
|
10
10
|
const EXCLUDED_ROUTES = ["/", "/login", "/register", "/forgot-password", "/reset-password", "/activate"];
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Detail pages title themselves `[Entity type] Entity name | App name`. Returns
|
|
14
|
+
* the entity name, or null when the document title is not (yet) a detail-page
|
|
15
|
+
* title — the normal state for the first tick after a client-side navigation,
|
|
16
|
+
* before the route's streamed metadata replaces the <title>.
|
|
17
|
+
*/
|
|
18
|
+
function readEntityTitle(): string | null {
|
|
19
|
+
if (typeof document === "undefined") return null;
|
|
20
|
+
|
|
21
|
+
const afterEntityType = document.title.split("]")[1];
|
|
22
|
+
if (!afterEntityType) return null;
|
|
23
|
+
|
|
24
|
+
return afterEntityType.split("|")[0]?.trim() || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
export function usePageTracker() {
|
|
13
28
|
const pathname = usePathname();
|
|
14
29
|
const [_recentPages, setRecentPages] = useAtom(recentPagesAtom);
|
|
30
|
+
const previousPathname = useRef<string | null>(null);
|
|
31
|
+
const previousTitle = useRef<string | null>(null);
|
|
15
32
|
|
|
16
33
|
useEffect(() => {
|
|
17
34
|
if (!pathname) return;
|
|
18
35
|
|
|
36
|
+
const arrivedByClientNavigation = previousPathname.current !== null && previousPathname.current !== pathname;
|
|
37
|
+
previousPathname.current = pathname;
|
|
38
|
+
|
|
19
39
|
// Exclude certain routes
|
|
20
40
|
if (EXCLUDED_ROUTES.some((route) => pathname === route || pathname.endsWith(route))) {
|
|
21
41
|
return;
|
|
@@ -42,28 +62,97 @@ export function usePageTracker() {
|
|
|
42
62
|
// Only use base path (module/id), ignoring any sub-paths
|
|
43
63
|
const baseUrl = `/${moduleName}/${entityId}`;
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
// A parsed title is only trustworthy on the entity's OWN page. Deeper paths
|
|
66
|
+
// collapse onto the same entry but title themselves after the section —
|
|
67
|
+
// "[Campaign] NPCs Venezia Obscura" — so they refresh the entry's recency
|
|
68
|
+
// without renaming it. An entity first seen through a sub-page keeps the
|
|
69
|
+
// module name until its own page is opened.
|
|
70
|
+
if (pathParts.length > 2) {
|
|
71
|
+
setRecentPages((prev) => {
|
|
72
|
+
const existing = prev.find((page) => page.url === baseUrl);
|
|
73
|
+
const refreshed: RecentPage = {
|
|
74
|
+
url: baseUrl,
|
|
75
|
+
title: existing?.title ?? foundModule.name,
|
|
76
|
+
moduleType: foundModule.name,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
return [refreshed, ...prev.filter((page) => page.url !== baseUrl)].slice(0, 10);
|
|
80
|
+
});
|
|
81
|
+
// This branch never seeds from document.title, but it must still track it:
|
|
82
|
+
// the next navigation's "has the title already changed" comparison (below)
|
|
83
|
+
// is meaningful only when compared against the title we last actually
|
|
84
|
+
// observed. Leaving previousTitle at whatever it was BEFORE this sub-page
|
|
85
|
+
// (e.g. still the entity's own page, several navigations back) would
|
|
86
|
+
// compare the next route's title against the wrong baseline — the
|
|
87
|
+
// comparison could go either way depending on incidental string overlap,
|
|
88
|
+
// rather than reflecting whether the next route's title has truly arrived.
|
|
89
|
+
previousTitle.current = document.title;
|
|
90
|
+
return;
|
|
52
91
|
}
|
|
53
92
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
};
|
|
93
|
+
let lastRecordedTitle: string | null = null;
|
|
94
|
+
|
|
95
|
+
const record = (pageTitle: string) => {
|
|
96
|
+
if (pageTitle === lastRecordedTitle) return;
|
|
97
|
+
lastRecordedTitle = pageTitle;
|
|
60
98
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
99
|
+
const newPage: RecentPage = {
|
|
100
|
+
url: baseUrl,
|
|
101
|
+
title: pageTitle,
|
|
102
|
+
moduleType: foundModule.name,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
};
|
|
64
105
|
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
setRecentPages((prev) => {
|
|
107
|
+
// Remove if already exists (to move to top)
|
|
108
|
+
const filtered = prev.filter((page) => page.url !== newPage.url);
|
|
109
|
+
|
|
110
|
+
// Add to beginning and limit to 10
|
|
111
|
+
return [newPage, ...filtered].slice(0, 10);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// A route commits BEFORE its streamed metadata lands, so on a client-side
|
|
116
|
+
// navigation document.title normally still belongs to the page we left.
|
|
117
|
+
// Seeding from it would stamp this entity with the previous entity's name,
|
|
118
|
+
// so the default is to seed with the module name and let the observer below
|
|
119
|
+
// correct it once the real title lands. On a hard load the title was
|
|
120
|
+
// server-rendered into the initial HTML and is already ours.
|
|
121
|
+
//
|
|
122
|
+
// But when the route's payload was already prefetched, the new title can
|
|
123
|
+
// land in the SAME commit as the navigation — there is no later mutation
|
|
124
|
+
// for the observer to catch, so the module-name fallback would stick
|
|
125
|
+
// forever. Comparing document.title against the title we last consumed
|
|
126
|
+
// (previousTitle) distinguishes the two cases: unchanged means it's still
|
|
127
|
+
// the page we left (ignore it, as above); changed means this route's own
|
|
128
|
+
// title has already arrived (trust it immediately).
|
|
129
|
+
const titleAlreadyChanged = document.title !== previousTitle.current;
|
|
130
|
+
const seededTitle = !arrivedByClientNavigation || titleAlreadyChanged ? readEntityTitle() : null;
|
|
131
|
+
|
|
132
|
+
record(seededTitle ?? foundModule.name);
|
|
133
|
+
previousTitle.current = document.title;
|
|
134
|
+
|
|
135
|
+
if (typeof document === "undefined") return;
|
|
136
|
+
|
|
137
|
+
const observer = new MutationObserver(() => {
|
|
138
|
+
// This effect's cleanup (which disconnects this observer) runs as a
|
|
139
|
+
// passive-effect flush, which React defers to AFTER the current
|
|
140
|
+
// microtask checkpoint. A MutationObserver callback fires as a
|
|
141
|
+
// microtask, immediately once its target mutates — including a mutation
|
|
142
|
+
// that belongs to the NEXT route, if we are still connected when it
|
|
143
|
+
// lands. Bail if the browser has already moved off this entry's page, so
|
|
144
|
+
// a stale observer never stamps the next route's title onto this one.
|
|
145
|
+
// window.location.pathname may carry a locale prefix, hence `includes`.
|
|
146
|
+
if (!window.location.pathname.includes(baseUrl)) return;
|
|
147
|
+
|
|
148
|
+
const title = readEntityTitle();
|
|
149
|
+
if (title) {
|
|
150
|
+
record(title);
|
|
151
|
+
previousTitle.current = document.title;
|
|
152
|
+
}
|
|
67
153
|
});
|
|
154
|
+
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
|
155
|
+
|
|
156
|
+
return () => observer.disconnect();
|
|
68
157
|
}, [pathname, setRecentPages]);
|
|
69
158
|
}
|