@carlonicora/nextjs-jsonapi 2.1.3 → 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.
Files changed (29) hide show
  1. package/dist/{BlockNoteEditor-76GLONZJ.js → BlockNoteEditor-5TMQ5YCD.js} +9 -9
  2. package/dist/{BlockNoteEditor-76GLONZJ.js.map → BlockNoteEditor-5TMQ5YCD.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-75CHO24E.mjs → BlockNoteEditor-HAUE4EPR.mjs} +2 -2
  4. package/dist/billing/index.js +310 -310
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-BFCCVHJ7.js → chunk-HPKCZMQX.js} +92 -30
  7. package/dist/chunk-HPKCZMQX.js.map +1 -0
  8. package/dist/{chunk-KIFJ7JRO.mjs → chunk-PGW2OP3L.mjs} +135 -73
  9. package/dist/chunk-PGW2OP3L.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +12 -0
  13. package/dist/components/index.d.ts +12 -0
  14. package/dist/components/index.js +2 -2
  15. package/dist/components/index.mjs +1 -1
  16. package/dist/contexts/index.js +2 -2
  17. package/dist/contexts/index.mjs +1 -1
  18. package/dist/features/help/index.js +31 -31
  19. package/dist/features/help/index.mjs +1 -1
  20. package/dist/features/tokenusage/index.js +56 -56
  21. package/dist/features/tokenusage/index.mjs +1 -1
  22. package/package.json +1 -1
  23. package/src/components/navigations/RecentPagesNavigator.tsx +64 -33
  24. package/src/components/navigations/__tests__/RecentPagesNavigator.spec.tsx +166 -0
  25. package/src/hooks/__tests__/usePageTracker.spec.tsx +296 -0
  26. package/src/hooks/usePageTracker.ts +108 -19
  27. package/dist/chunk-BFCCVHJ7.js.map +0 -1
  28. package/dist/chunk-KIFJ7JRO.mjs.map +0 -1
  29. /package/dist/{BlockNoteEditor-75CHO24E.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
- let pageTitle = foundModule.name;
46
- if (typeof document !== "undefined") {
47
- const titleParts = document.title.split("]");
48
- if (titleParts[1]) {
49
- const cleanTitle = titleParts[1].split("|")[0]?.trim();
50
- pageTitle = cleanTitle || foundModule.name;
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
- const newPage: RecentPage = {
55
- url: baseUrl,
56
- title: pageTitle,
57
- moduleType: foundModule.name,
58
- timestamp: Date.now(),
59
- };
93
+ let lastRecordedTitle: string | null = null;
94
+
95
+ const record = (pageTitle: string) => {
96
+ if (pageTitle === lastRecordedTitle) return;
97
+ lastRecordedTitle = pageTitle;
60
98
 
61
- setRecentPages((prev) => {
62
- // Remove if already exists (to move to top)
63
- const filtered = prev.filter((page) => page.url !== newPage.url);
99
+ const newPage: RecentPage = {
100
+ url: baseUrl,
101
+ title: pageTitle,
102
+ moduleType: foundModule.name,
103
+ timestamp: Date.now(),
104
+ };
64
105
 
65
- // Add to beginning and limit to 10
66
- return [newPage, ...filtered].slice(0, 10);
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
  }