@carlonicora/nextjs-jsonapi 3.28.2 → 4.0.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 (213) hide show
  1. package/dist/{BlockNoteEditor-36VL2TNM.js → BlockNoteEditor-BXMTGKDV.js} +21 -20
  2. package/dist/BlockNoteEditor-BXMTGKDV.js.map +1 -0
  3. package/dist/{BlockNoteEditor-4IDXT6ZU.mjs → BlockNoteEditor-FGVQP35E.mjs} +9 -8
  4. package/dist/{BlockNoteEditor-4IDXT6ZU.mjs.map → BlockNoteEditor-FGVQP35E.mjs.map} +1 -1
  5. package/dist/{BlockNoteViewer-KRTAFTUX.mjs → BlockNoteViewer-DQYT6IJW.mjs} +3 -3
  6. package/dist/{BlockNoteViewer-7BFNTN4E.js → BlockNoteViewer-YH7G66PS.js} +4 -4
  7. package/dist/{BlockNoteViewer-7BFNTN4E.js.map → BlockNoteViewer-YH7G66PS.js.map} +1 -1
  8. package/dist/{HandbookSectionInterface-DgYH--Ik.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +1 -1
  9. package/dist/{HandbookSectionInterface-Ck1wZJ81.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +1 -1
  10. package/dist/{JsonApiRequest-UHOWNI3O.mjs → JsonApiRequest-MEP6AXDT.mjs} +2 -2
  11. package/dist/JsonApiRequest-OB6WL4LA.js +26 -0
  12. package/dist/{JsonApiRequest-TE5WD3WS.js.map → JsonApiRequest-OB6WL4LA.js.map} +1 -1
  13. package/dist/ServerSession-DFNNNkg7.d.ts +29 -0
  14. package/dist/ServerSession-DK1z0_Ql.d.mts +29 -0
  15. package/dist/{analytics.modules-CfczU12P.d.ts → analytics.modules-Bsacu6ik.d.ts} +1 -1
  16. package/dist/{analytics.modules-pzGwMASI.d.mts → analytics.modules-txdCLNG-.d.mts} +1 -1
  17. package/dist/billing/index.js +404 -402
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +10 -8
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-7B4E42R5.mjs → chunk-3CZ2GK5R.mjs} +33 -9
  22. package/dist/chunk-3CZ2GK5R.mjs.map +1 -0
  23. package/dist/{chunk-4Z45NYPR.js → chunk-5W5MN7JP.js} +14 -83
  24. package/dist/chunk-5W5MN7JP.js.map +1 -0
  25. package/dist/{chunk-CAKOFJZD.js → chunk-6OFANJTE.js} +195 -142
  26. package/dist/chunk-6OFANJTE.js.map +1 -0
  27. package/dist/{chunk-5TRFNKJS.js → chunk-6TUB7Y6T.js} +23 -25
  28. package/dist/chunk-6TUB7Y6T.js.map +1 -0
  29. package/dist/{chunk-37DMFVLN.mjs → chunk-7SCREMTX.mjs} +4 -3
  30. package/dist/{chunk-37DMFVLN.mjs.map → chunk-7SCREMTX.mjs.map} +1 -1
  31. package/dist/{chunk-Q6J2ICKO.mjs → chunk-ADZSH3MG.mjs} +3 -72
  32. package/dist/chunk-ADZSH3MG.mjs.map +1 -0
  33. package/dist/{chunk-E3I4MT53.mjs → chunk-AQZ3T4BR.mjs} +71 -18
  34. package/dist/chunk-AQZ3T4BR.mjs.map +1 -0
  35. package/dist/{chunk-SJLXPHK5.mjs → chunk-E2LENTUH.mjs} +22 -24
  36. package/dist/chunk-E2LENTUH.mjs.map +1 -0
  37. package/dist/chunk-EBEXWN3I.mjs +17 -0
  38. package/dist/chunk-EBEXWN3I.mjs.map +1 -0
  39. package/dist/chunk-G4JR3SFV.js +28 -0
  40. package/dist/chunk-G4JR3SFV.js.map +1 -0
  41. package/dist/chunk-IK65TDQH.js +15 -0
  42. package/dist/chunk-IK65TDQH.js.map +1 -0
  43. package/dist/{chunk-GHFMKH3M.js → chunk-JEQ34Z5E.js} +62 -38
  44. package/dist/chunk-JEQ34Z5E.js.map +1 -0
  45. package/dist/{chunk-KOTKKBJT.js → chunk-NFCXUAE6.js} +1328 -1183
  46. package/dist/chunk-NFCXUAE6.js.map +1 -0
  47. package/dist/chunk-OEOK6A3P.js +17 -0
  48. package/dist/chunk-OEOK6A3P.js.map +1 -0
  49. package/dist/chunk-PW42LRSL.mjs +28 -0
  50. package/dist/chunk-PW42LRSL.mjs.map +1 -0
  51. package/dist/{chunk-U65JPKO3.mjs → chunk-R6ZYB4VN.mjs} +2 -2
  52. package/dist/chunk-T7SAW3UW.mjs +15 -0
  53. package/dist/chunk-T7SAW3UW.mjs.map +1 -0
  54. package/dist/{chunk-GKDHYB2R.js → chunk-TWBZ2GSX.js} +3 -3
  55. package/dist/{chunk-GKDHYB2R.js.map → chunk-TWBZ2GSX.js.map} +1 -1
  56. package/dist/{chunk-26MQIJNB.js → chunk-WGVNJXZO.js} +4 -3
  57. package/dist/chunk-WGVNJXZO.js.map +1 -0
  58. package/dist/{chunk-HPUV6FBW.mjs → chunk-WJC4SYRL.mjs} +2131 -1986
  59. package/dist/chunk-WJC4SYRL.mjs.map +1 -0
  60. package/dist/client/index.d.mts +3 -3
  61. package/dist/client/index.d.ts +3 -3
  62. package/dist/client/index.js +6 -5
  63. package/dist/client/index.js.map +1 -1
  64. package/dist/client/index.mjs +13 -12
  65. package/dist/components/index.d.mts +16 -6
  66. package/dist/components/index.d.ts +16 -6
  67. package/dist/components/index.js +8 -5
  68. package/dist/components/index.js.map +1 -1
  69. package/dist/components/index.mjs +9 -6
  70. package/dist/{config-3N1LSrcC.d.ts → config-B6UB7j69.d.ts} +1 -1
  71. package/dist/{config-BmufyEj6.d.mts → config-CEAq4Ozy.d.mts} +10 -1
  72. package/dist/{config-AVtuvfM1.d.ts → config-DydDHCfb.d.ts} +10 -1
  73. package/dist/{config-BSKTYDoh.d.mts → config-n-ZpPmOL.d.mts} +1 -1
  74. package/dist/contexts/index.d.mts +28 -4
  75. package/dist/contexts/index.d.ts +28 -4
  76. package/dist/contexts/index.js +10 -5
  77. package/dist/contexts/index.js.map +1 -1
  78. package/dist/contexts/index.mjs +9 -4
  79. package/dist/core/index.d.mts +6 -6
  80. package/dist/core/index.d.ts +6 -6
  81. package/dist/core/index.js +5 -5
  82. package/dist/core/index.js.map +1 -1
  83. package/dist/core/index.mjs +4 -4
  84. package/dist/features/analytics/index.d.mts +2 -2
  85. package/dist/features/analytics/index.d.ts +2 -2
  86. package/dist/features/analytics/index.js +124 -123
  87. package/dist/features/analytics/index.js.map +1 -1
  88. package/dist/features/analytics/index.mjs +6 -5
  89. package/dist/features/analytics/index.mjs.map +1 -1
  90. package/dist/features/analytics/server.d.mts +10 -2
  91. package/dist/features/analytics/server.d.ts +10 -2
  92. package/dist/features/analytics/server.js +8 -6
  93. package/dist/features/analytics/server.js.map +1 -1
  94. package/dist/features/analytics/server.mjs +6 -4
  95. package/dist/features/analytics/server.mjs.map +1 -1
  96. package/dist/features/help/index.d.mts +2 -2
  97. package/dist/features/help/index.d.ts +2 -2
  98. package/dist/features/help/index.js +39 -38
  99. package/dist/features/help/index.js.map +1 -1
  100. package/dist/features/help/index.mjs +5 -4
  101. package/dist/features/help/index.mjs.map +1 -1
  102. package/dist/features/tokenusage/index.d.mts +2 -2
  103. package/dist/features/tokenusage/index.d.ts +2 -2
  104. package/dist/features/tokenusage/index.js +127 -126
  105. package/dist/features/tokenusage/index.js.map +1 -1
  106. package/dist/features/tokenusage/index.mjs +6 -5
  107. package/dist/features/tokenusage/index.mjs.map +1 -1
  108. package/dist/index.d.mts +5 -5
  109. package/dist/index.d.ts +5 -5
  110. package/dist/index.js +8 -6
  111. package/dist/index.js.map +1 -1
  112. package/dist/index.mjs +11 -9
  113. package/dist/next-config/index.d.mts +43 -0
  114. package/dist/next-config/index.d.ts +43 -0
  115. package/dist/next-config/index.js +230 -0
  116. package/dist/next-config/index.js.map +1 -0
  117. package/dist/next-config/index.mjs +230 -0
  118. package/dist/next-config/index.mjs.map +1 -0
  119. package/dist/{request-FXF63NSQ.mjs → request-3IUMNVOS.mjs} +2 -2
  120. package/dist/request-LZBAOT3Y.js +8 -0
  121. package/dist/request-LZBAOT3Y.js.map +1 -0
  122. package/dist/server/cachedGet.d.mts +28 -0
  123. package/dist/server/cachedGet.d.ts +28 -0
  124. package/dist/server/cachedGet.js +60 -0
  125. package/dist/server/cachedGet.js.map +1 -0
  126. package/dist/server/cachedGet.mjs +60 -0
  127. package/dist/server/cachedGet.mjs.map +1 -0
  128. package/dist/server/cachedGet.stub.d.mts +7 -0
  129. package/dist/server/cachedGet.stub.d.ts +7 -0
  130. package/dist/server/cachedGet.stub.js +17 -0
  131. package/dist/server/cachedGet.stub.js.map +1 -0
  132. package/dist/server/cachedGet.stub.mjs +17 -0
  133. package/dist/server/cachedGet.stub.mjs.map +1 -0
  134. package/dist/server/index.d.mts +27 -25
  135. package/dist/server/index.d.ts +27 -25
  136. package/dist/server/index.js +13 -6
  137. package/dist/server/index.js.map +1 -1
  138. package/dist/server/index.mjs +15 -8
  139. package/dist/testing/index.js.map +1 -1
  140. package/dist/testing/index.mjs.map +1 -1
  141. package/dist/{types-D0uLYV2P.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  142. package/dist/{types-Cn153-mH.d.ts → types-bsm8Fp55.d.ts} +1 -1
  143. package/dist/{usePageUrlGenerator-B-3FRKBD.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  144. package/dist/{usePageUrlGenerator-CCJ_cdQy.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  145. package/package.json +12 -1
  146. package/src/components/forms/__tests__/EditorSheet.test.tsx +25 -1
  147. package/src/components/forms/__tests__/useEditorDialog.test.ts +164 -1
  148. package/src/components/forms/useEditorDialog.ts +69 -2
  149. package/src/contexts/SessionContext.tsx +20 -0
  150. package/src/contexts/SocketContext.tsx +30 -3
  151. package/src/contexts/ViewportContext.tsx +17 -2
  152. package/src/contexts/__tests__/SessionContext.test.tsx +58 -0
  153. package/src/contexts/__tests__/SocketContext.session.test.tsx +77 -0
  154. package/src/contexts/index.ts +1 -0
  155. package/src/core/abstracts/AbstractService.ts +4 -6
  156. package/src/core/abstracts/__tests__/AbstractService.locale.test.ts +64 -0
  157. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +5 -1
  158. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +5 -1
  159. package/src/features/notification/contexts/NotificationContext.tsx +5 -2
  160. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +22 -0
  161. package/src/features/user/contexts/CurrentUserContext.tsx +7 -5
  162. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +63 -3
  163. package/src/hooks/__tests__/useSocket.render.test.ts +22 -0
  164. package/src/hooks/useSocket.ts +0 -1
  165. package/src/i18n/__tests__/config.test.ts +27 -0
  166. package/src/i18n/config.ts +17 -0
  167. package/src/i18n/index.ts +2 -0
  168. package/src/next-config/__tests__/createNextConfig.test.ts +268 -0
  169. package/src/next-config/csp.ts +128 -0
  170. package/src/next-config/headers.ts +61 -0
  171. package/src/next-config/index.ts +114 -0
  172. package/src/server/JsonApiServer.ts +3 -1
  173. package/src/server/ServerSession.ts +29 -1
  174. package/src/server/__tests__/JsonApiServer.test.ts +41 -0
  175. package/src/server/__tests__/ServerSession.read.test.ts +67 -0
  176. package/src/server/__tests__/cache.test.ts +48 -0
  177. package/src/server/__tests__/cachedGet.test.ts +125 -0
  178. package/src/server/__tests__/request.test.ts +96 -1
  179. package/src/server/cache.ts +21 -4
  180. package/src/server/cacheDigest.ts +10 -0
  181. package/src/server/cacheProfiles.ts +15 -0
  182. package/src/server/cachedGet.stub.ts +15 -0
  183. package/src/server/cachedGet.ts +80 -0
  184. package/src/server/request.ts +33 -32
  185. package/src/shadcnui/ui/__tests__/sidebar.menu-skeleton.test.tsx +50 -0
  186. package/src/shadcnui/ui/__tests__/sidebar.session.test.tsx +133 -0
  187. package/src/shadcnui/ui/sidebar.tsx +121 -18
  188. package/src/testing/matchers/jsonApiMatchers.ts +1 -0
  189. package/src/unified/JsonApiRequest.ts +4 -1
  190. package/src/unified/__tests__/JsonApiGet.cache.test.ts +51 -0
  191. package/src/utils/__tests__/map-thenable.test.ts +29 -0
  192. package/src/utils/__tests__/use-mobile.test.tsx +102 -1
  193. package/src/utils/map-thenable.ts +15 -0
  194. package/src/utils/use-mobile.tsx +13 -4
  195. package/dist/BlockNoteEditor-36VL2TNM.js.map +0 -1
  196. package/dist/JsonApiRequest-TE5WD3WS.js +0 -26
  197. package/dist/chunk-26MQIJNB.js.map +0 -1
  198. package/dist/chunk-4Z45NYPR.js.map +0 -1
  199. package/dist/chunk-5TRFNKJS.js.map +0 -1
  200. package/dist/chunk-7B4E42R5.mjs.map +0 -1
  201. package/dist/chunk-CAKOFJZD.js.map +0 -1
  202. package/dist/chunk-E3I4MT53.mjs.map +0 -1
  203. package/dist/chunk-GHFMKH3M.js.map +0 -1
  204. package/dist/chunk-HPUV6FBW.mjs.map +0 -1
  205. package/dist/chunk-KOTKKBJT.js.map +0 -1
  206. package/dist/chunk-Q6J2ICKO.mjs.map +0 -1
  207. package/dist/chunk-SJLXPHK5.mjs.map +0 -1
  208. package/dist/request-ZTX6I63X.js +0 -8
  209. package/dist/request-ZTX6I63X.js.map +0 -1
  210. /package/dist/{BlockNoteViewer-KRTAFTUX.mjs.map → BlockNoteViewer-DQYT6IJW.mjs.map} +0 -0
  211. /package/dist/{JsonApiRequest-UHOWNI3O.mjs.map → JsonApiRequest-MEP6AXDT.mjs.map} +0 -0
  212. /package/dist/{chunk-U65JPKO3.mjs.map → chunk-R6ZYB4VN.mjs.map} +0 -0
  213. /package/dist/{request-FXF63NSQ.mjs.map → request-3IUMNVOS.mjs.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { act, render, screen } from "@testing-library/react";
3
+ import { Activity } from "react";
3
4
  import userEvent from "@testing-library/user-event";
4
5
  import { useForm } from "react-hook-form";
5
6
  import { zodResolver } from "@hookform/resolvers/zod";
@@ -257,4 +258,27 @@ describe("EditorSheet", () => {
257
258
  expect(submitButton).toBeDisabled();
258
259
  });
259
260
  });
261
+
262
+ describe("Activity hide and reveal (back/forward)", () => {
263
+ it("uncontrolled sheet is closed and the form reset after Activity reveal", async () => {
264
+ const page = (mode: "visible" | "hidden") => (
265
+ <Activity mode={mode}>
266
+ <TestEditor />
267
+ </Activity>
268
+ );
269
+ const r = render(page("visible"));
270
+
271
+ await userEvent.click(screen.getByText("ui.buttons.create"));
272
+ await userEvent.type(screen.getByTestId("name-input"), "Left behind");
273
+ expect(screen.getByTestId("name-input")).toHaveValue("Left behind");
274
+
275
+ act(() => r.rerender(page("hidden")));
276
+ act(() => r.rerender(page("visible")));
277
+
278
+ expect(screen.queryByTestId("name-input")).toBeNull();
279
+
280
+ await userEvent.click(screen.getByText("ui.buttons.create"));
281
+ expect(screen.getByTestId("name-input")).toHaveValue("");
282
+ });
283
+ });
260
284
  });
@@ -1,7 +1,80 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
2
+ import { renderHook, act, render } from "@testing-library/react";
3
+ import { Activity, StrictMode, createElement, useState } from "react";
3
4
  import { useEditorDialog } from "../useEditorDialog";
4
5
 
6
+ type ActivityMode = "visible" | "hidden";
7
+ type HookApi = ReturnType<typeof useEditorDialog>;
8
+
9
+ /**
10
+ * Mounts a sheet driven by `useEditorDialog` inside a React `<Activity>`, the
11
+ * way Next 16.4 keeps a route mounted but hidden across back/forward. The page
12
+ * component sits inside the Activity, so its own state (the parent's
13
+ * `dialogOpen`) survives the hide exactly as a route's state does.
14
+ */
15
+ function renderInActivity(opts: {
16
+ controlled?: boolean;
17
+ initialParentOpen?: boolean;
18
+ forceShow?: boolean;
19
+ strict?: boolean;
20
+ isFormDirty?: () => boolean;
21
+ onClose?: () => void;
22
+ }) {
23
+ const renders: boolean[] = [];
24
+ const parentSpy = vi.fn();
25
+ let api!: HookApi;
26
+ let parentOpen: boolean | undefined;
27
+ let setParentOpen!: (open: boolean) => void;
28
+
29
+ function Sheet(props: { dialogOpen?: boolean; onDialogOpenChange?: (open: boolean) => void }) {
30
+ api = useEditorDialog(opts.isFormDirty ?? (() => false), {
31
+ ...props,
32
+ forceShow: opts.forceShow,
33
+ onClose: opts.onClose,
34
+ });
35
+ renders.push(api.open);
36
+ return null;
37
+ }
38
+
39
+ function Page() {
40
+ const [open, setOpen] = useState(opts.initialParentOpen ?? false);
41
+ parentOpen = open;
42
+ setParentOpen = setOpen;
43
+ if (!opts.controlled) return createElement(Sheet);
44
+ return createElement(Sheet, {
45
+ dialogOpen: open,
46
+ onDialogOpenChange: (next: boolean) => {
47
+ parentSpy(next);
48
+ setOpen(next);
49
+ },
50
+ });
51
+ }
52
+
53
+ function App({ mode }: { mode: ActivityMode }) {
54
+ return createElement(Activity, { mode }, createElement(Page));
55
+ }
56
+
57
+ // StrictMode only double-invokes effects when it wraps the ROOT element.
58
+ const root = (mode: ActivityMode) =>
59
+ opts.strict ? createElement(StrictMode, null, createElement(App, { mode })) : createElement(App, { mode });
60
+
61
+ const r = render(root("visible"));
62
+
63
+ return {
64
+ renders,
65
+ parentSpy,
66
+ get api() {
67
+ return api;
68
+ },
69
+ get parentOpen() {
70
+ return parentOpen;
71
+ },
72
+ openFromParent: () => act(() => setParentOpen(true)),
73
+ hide: () => act(() => r.rerender(root("hidden"))),
74
+ reveal: () => act(() => r.rerender(root("visible"))),
75
+ };
76
+ }
77
+
5
78
  describe("useEditorDialog", () => {
6
79
  beforeEach(() => {
7
80
  vi.clearAllMocks();
@@ -281,4 +354,94 @@ describe("useEditorDialog", () => {
281
354
  expect(outer.result.current.open).toBe(true);
282
355
  });
283
356
  });
357
+
358
+ describe("Activity hide and reveal (back/forward)", () => {
359
+ it("controlled sheet does not reopen after Activity reveal", () => {
360
+ const t = renderInActivity({ controlled: true, initialParentOpen: true });
361
+ expect(t.api.open).toBe(true);
362
+ t.parentSpy.mockClear();
363
+
364
+ t.hide();
365
+ const revealedAt = t.renders.length;
366
+ t.reveal();
367
+
368
+ expect(t.parentSpy.mock.calls).toEqual([[false]]);
369
+ expect(t.parentOpen).toBe(false);
370
+ expect(t.api.open).toBe(false);
371
+ expect(t.renders.slice(revealedAt)).not.toContain(true);
372
+ });
373
+
374
+ it("sheet opened after mount is closed after Activity reveal", () => {
375
+ const t = renderInActivity({});
376
+ expect(t.api.open).toBe(false);
377
+ act(() => t.api.setOpen(true));
378
+ expect(t.api.open).toBe(true);
379
+
380
+ t.hide();
381
+ t.reveal();
382
+
383
+ expect(t.api.open).toBe(false);
384
+ });
385
+
386
+ it("forceShow sheet stays closed after Activity reveal", () => {
387
+ const t = renderInActivity({ forceShow: true });
388
+ expect(t.api.open).toBe(true);
389
+
390
+ t.hide();
391
+ t.reveal();
392
+
393
+ expect(t.api.open).toBe(false);
394
+ });
395
+
396
+ it("stays closed under StrictMode reveal", () => {
397
+ const t = renderInActivity({ controlled: true, strict: true });
398
+ t.openFromParent();
399
+ expect(t.api.open).toBe(true);
400
+
401
+ t.hide();
402
+ const revealedAt = t.renders.length;
403
+ t.reveal();
404
+
405
+ // Strict Mode doubles the notify effect too, so parent calls are not counted.
406
+ expect(t.renders.slice(revealedAt)).not.toContain(true);
407
+ expect(t.api.open).toBe(false);
408
+ expect(t.parentOpen).toBe(false);
409
+ });
410
+
411
+ it("controlled sheet still opens on first mount under StrictMode", () => {
412
+ const t = renderInActivity({ controlled: true, initialParentOpen: true, strict: true });
413
+ expect(t.api.open).toBe(true);
414
+ expect(t.parentOpen).toBe(true);
415
+ });
416
+
417
+ it("discard confirmation is closed after Activity reveal", () => {
418
+ const t = renderInActivity({ isFormDirty: () => true });
419
+ act(() => t.api.setOpen(true));
420
+ act(() => t.api.handleOpenChange(false));
421
+ expect(t.api.discardDialogProps.open).toBe(true);
422
+
423
+ t.hide();
424
+ t.reveal();
425
+
426
+ expect(t.api.discardDialogProps.open).toBe(false);
427
+ expect(t.api.open).toBe(false);
428
+ });
429
+
430
+ it("onClose is called once per hide and reveal", () => {
431
+ const onClose = vi.fn();
432
+ const t = renderInActivity({ onClose });
433
+ act(() => t.api.setOpen(true));
434
+ onClose.mockClear();
435
+
436
+ t.hide();
437
+ t.reveal();
438
+
439
+ expect(onClose).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ it("forceShow sheet still opens on first mount under StrictMode", () => {
443
+ const t = renderInActivity({ forceShow: true, strict: true });
444
+ expect(t.api.open).toBe(true);
445
+ });
446
+ });
284
447
  });
@@ -62,11 +62,75 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
62
62
  // each other every render → "Maximum update depth exceeded".
63
63
  const syncingFromProp = useRef(false);
64
64
 
65
+ // Mirrors `open` on every render. The reset effect below has an empty
66
+ // dependency list, so its cleanup closes over the FIRST render's `open`; it
67
+ // must read the current value from here instead.
68
+ const openRef = useRef(open);
69
+ openRef.current = open;
70
+ const showDiscardRef = useRef(showDiscardConfirm);
71
+ showDiscardRef.current = showDiscardConfirm;
72
+
73
+ // Set by the reset cleanup when the route was hidden with the sheet (or its
74
+ // discard confirmation) open; consumed by the reset effect on reveal.
75
+ const hiddenWhileOpen = useRef(false);
76
+ // Latch armed on reveal: the parent's `dialogOpen` is still the stale `true`
77
+ // it held when the route was hidden, and must not re-open the sheet. Cleared
78
+ // by the first `dialogOpen === false`.
79
+ const ignoreTrueFromParent = useRef(false);
80
+
81
+ // Reset when <Activity> hides the route (Next 16.4 keeps routes mounted across
82
+ // back/forward). After back/forward an editor sheet must be closed with its
83
+ // form reset, exactly as before 16.4. Measured in React 19.3:
84
+ // 1. Hiding runs every effect cleanup and revealing re-runs every effect with
85
+ // mount semantics, while `useState` survives. A setState in the cleanup is
86
+ // applied while hidden, so the sheet is already closed on reveal and
87
+ // EditorSheet's close branch resets the form.
88
+ // 2. An empty-dependency cleanup reads the first render's `open`, hence
89
+ // `openRef`.
90
+ // 3. Effects run in declaration order. This effect is declared BEFORE the
91
+ // dialogOpen sync effect, so the latch is armed before the sync effect sees
92
+ // the parent's stale `true`; declared after, the sheet re-opens.
93
+ // 4. It never calls `onDialogOpenChange`. The notify effect re-runs on reveal
94
+ // with `open === false` and tells the parent once; calling it here too
95
+ // would tell the parent twice.
96
+ // Strict Mode's fake unmount on first mount finds `openRef` still false, so
97
+ // the latch is never armed then and a controlled or forceShow sheet still
98
+ // opens on mount.
99
+ // A real unmount also runs the cleanup; the setStates on an unmounted
100
+ // component are no-ops.
101
+ useEffect(() => {
102
+ if (hiddenWhileOpen.current) {
103
+ hiddenWhileOpen.current = false;
104
+ ignoreTrueFromParent.current = true;
105
+ }
106
+ return () => {
107
+ const wasOpen = openRef.current || showDiscardRef.current;
108
+ hiddenWhileOpen.current = wasOpen;
109
+ // Only when something is open: on Strict Mode's fake unmount the
110
+ // forceShow effect's setOpen(true) is still pending (`openRef` is false),
111
+ // and an unconditional setOpen(false) here would cancel it for good — the
112
+ // forceShow guard below does not open again for an unchanged prop.
113
+ if (wasOpen) {
114
+ setOpen(false);
115
+ setShowDiscardConfirm(false);
116
+ }
117
+ };
118
+ }, []);
119
+
65
120
  // Sync open state from external dialogOpen prop.
66
121
  // `open` is intentionally NOT a dependency: this effect must run only when the
67
122
  // prop changes. Adding `open` would re-fire on internal closes and immediately
68
123
  // re-open the dialog (dialogOpen still true while open just went false).
124
+ // After an <Activity> reveal it also re-runs with mount semantics while the
125
+ // parent's `dialogOpen` is still the stale `true`: the `ignoreTrueFromParent`
126
+ // latch drops that `true` until the parent reports `false` (the notify effect
127
+ // tells it). A latch, not a one-shot flag: under Strict Mode the reveal runs
128
+ // this effect twice, and a one-shot flag let the second pass re-open the sheet.
69
129
  useEffect(() => {
130
+ if (ignoreTrueFromParent.current) {
131
+ if (options?.dialogOpen === true) return;
132
+ if (options?.dialogOpen === false) ignoreTrueFromParent.current = false;
133
+ }
70
134
  if (options?.dialogOpen !== undefined && options.dialogOpen !== open) {
71
135
  syncingFromProp.current = true;
72
136
  setOpen(options.dialogOpen);
@@ -94,9 +158,12 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
94
158
  onDialogOpenChangeRef.current?.(open);
95
159
  }, [open]);
96
160
 
97
- // Force show
161
+ // Force show — only on a change to `true`. The effect re-runs on an
162
+ // <Activity> reveal with the prop unchanged; opening then would undo the reset.
163
+ const lastForceShow = useRef<boolean | undefined>(undefined);
98
164
  useEffect(() => {
99
- if (options?.forceShow) setOpen(true);
165
+ if (options?.forceShow && lastForceShow.current !== true) setOpen(true);
166
+ lastForceShow.current = options?.forceShow;
100
167
  }, [options?.forceShow]);
101
168
 
102
169
  // Call onClose when dialog closes
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { createContext, use, type ReactNode } from "react";
4
+ import type { SessionSnapshot } from "../server/ServerSession";
5
+
6
+ const SessionContext = createContext<Promise<SessionSnapshot> | null>(null);
7
+
8
+ export function SessionProvider({ session, children }: { session: Promise<SessionSnapshot>; children: ReactNode }) {
9
+ return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
10
+ }
11
+
12
+ /**
13
+ * Unwraps the session promise with `use()`. Suspends the caller until the
14
+ * promise resolves, so call it only from a leaf behind its own `<Suspense>`.
15
+ */
16
+ export function useSession(): SessionSnapshot {
17
+ const session = use(SessionContext);
18
+ if (!session) throw new Error("useSession must be used within a SessionProvider");
19
+ return use(session);
20
+ }
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import React, { createContext, useContext } from "react";
3
+ import React, { createContext, useContext, useEffect, useState } from "react";
4
4
  import { NotificationInterface } from "../features/notification/data";
5
5
  import { useSocket } from "../hooks";
6
+ import type { SessionSnapshot } from "../server/ServerSession";
6
7
 
7
8
  interface SocketContextProps {
8
9
  socket: ReturnType<typeof useSocket>["socket"];
@@ -32,11 +33,37 @@ export const SocketContext = createContext<SocketContextProps>(defaultContextVal
32
33
 
33
34
  interface SocketProviderProps {
34
35
  token?: string;
36
+ /**
37
+ * The request's session snapshot as a promise. The token is resolved in an
38
+ * effect, so this provider never suspends and its children render at once;
39
+ * the socket connects when the promise settles.
40
+ */
41
+ session?: Promise<SessionSnapshot>;
35
42
  children: React.ReactNode;
36
43
  }
37
44
 
38
- export const SocketProvider: React.FC<SocketProviderProps> = ({ token, children }) => {
39
- const socketHookData = token ? useSocket({ token }) : null;
45
+ export const SocketProvider: React.FC<SocketProviderProps> = ({ token, session, children }) => {
46
+ const [sessionToken, setSessionToken] = useState<string | undefined>(undefined);
47
+
48
+ useEffect(() => {
49
+ if (!session) return;
50
+ let active = true;
51
+ // Callback form: a server-passed promise's then() returns undefined, so it
52
+ // cannot be chained (see utils/map-thenable).
53
+ session.then(
54
+ (snapshot) => {
55
+ if (active) setSessionToken(snapshot.token);
56
+ },
57
+ () => {},
58
+ );
59
+ return () => {
60
+ active = false;
61
+ };
62
+ }, [session]);
63
+
64
+ const resolvedToken = token ?? sessionToken;
65
+ // Called unconditionally: useSocket returns early while the token is falsy.
66
+ const socketHookData = useSocket({ token: resolvedToken as string });
40
67
 
41
68
  const sendMessage = (event: string, data: any) => {
42
69
  const currentSocket = socketHookData ? socketHookData.socket : defaultContextValue.socket;
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import type { SessionSnapshot } from "../server/ServerSession";
5
+ import { mapThenable } from "../utils/map-thenable";
4
6
  import { getViewportContext, measureViewport, persistViewport, subscribeViewport } from "../utils/use-mobile";
5
7
 
6
8
  /**
@@ -24,13 +26,26 @@ import { getViewportContext, measureViewport, persistViewport, subscribeViewport
24
26
  */
25
27
  export function ViewportProvider({
26
28
  initialIsMobile,
29
+ session,
27
30
  children,
28
31
  }: {
29
- initialIsMobile: boolean;
32
+ initialIsMobile?: boolean;
33
+ /**
34
+ * Alternative to `initialIsMobile` (pass exactly one): the request's session
35
+ * snapshot as a promise. The provider never awaits it, so its children render
36
+ * at once; only `useIsMobile()` callers suspend until it settles. The first
37
+ * client measurement replaces it with a boolean, as with `initialIsMobile`.
38
+ */
39
+ session?: Promise<SessionSnapshot>;
30
40
  children: React.ReactNode;
31
41
  }) {
32
42
  const ViewportContext = getViewportContext();
33
- const [isMobile, setIsMobile] = React.useState<boolean>(initialIsMobile);
43
+ const seed = React.useMemo<boolean | Promise<boolean>>(
44
+ () => (session ? mapThenable(session, (snapshot) => snapshot.isMobile) : (initialIsMobile ?? false)),
45
+ // Created once: later measurements own the value.
46
+ [],
47
+ );
48
+ const [isMobile, setIsMobile] = React.useState<boolean | Promise<boolean>>(seed);
34
49
 
35
50
  React.useEffect(
36
51
  () =>
@@ -0,0 +1,58 @@
1
+ import { act, render, renderHook, screen } from "@testing-library/react";
2
+ import { Suspense } from "react";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { SessionSnapshot } from "../../server/ServerSession";
5
+ import { SessionProvider, useSession } from "../SessionContext";
6
+
7
+ const snapshot: SessionSnapshot = {
8
+ isLogged: true,
9
+ isAdministrator: false,
10
+ token: "t1",
11
+ sidebarOpen: true,
12
+ isMobile: false,
13
+ };
14
+
15
+ function Leaf() {
16
+ return <span>{useSession().token}</span>;
17
+ }
18
+
19
+ describe("SessionContext", () => {
20
+ it("renders children without waiting for the session", () => {
21
+ render(
22
+ <SessionProvider session={new Promise<SessionSnapshot>(() => {})}>
23
+ <p>child</p>
24
+ </SessionProvider>,
25
+ );
26
+
27
+ expect(screen.getByText("child")).toBeInTheDocument();
28
+ });
29
+
30
+ it("useSession suspends until the promise resolves and then returns it", async () => {
31
+ let resolve!: (value: SessionSnapshot) => void;
32
+ const session = new Promise<SessionSnapshot>((r) => {
33
+ resolve = r;
34
+ });
35
+
36
+ // An async act around the suspending render lets React schedule the retry
37
+ // when the promise resolves; a plain render() never re-renders the leaf.
38
+ await act(async () => {
39
+ render(
40
+ <SessionProvider session={session}>
41
+ <Suspense fallback="waiting">
42
+ <Leaf />
43
+ </Suspense>
44
+ </SessionProvider>,
45
+ );
46
+ });
47
+
48
+ expect(screen.getByText("waiting")).toBeInTheDocument();
49
+
50
+ await act(async () => resolve(snapshot));
51
+
52
+ expect(screen.getByText("t1")).toBeInTheDocument();
53
+ });
54
+
55
+ it("useSession throws outside a provider", () => {
56
+ expect(() => renderHook(() => useSession())).toThrow(/SessionProvider/);
57
+ });
58
+ });
@@ -0,0 +1,77 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { SessionSnapshot } from "../../server/ServerSession";
4
+
5
+ const { useSocketMock } = vi.hoisted(() => ({
6
+ useSocketMock: vi.fn((_options: { token?: string }) => ({
7
+ socket: null,
8
+ isConnected: false,
9
+ messages: [],
10
+ socketNotifications: [],
11
+ sendMessage: () => {},
12
+ removeMessage: () => {},
13
+ removeSocketNotification: () => {},
14
+ clearMessages: () => {},
15
+ clearSocketNotifications: () => {},
16
+ })),
17
+ }));
18
+
19
+ vi.mock("../../hooks/useSocket", () => ({ useSocket: useSocketMock }));
20
+
21
+ import { SocketProvider } from "../SocketContext";
22
+
23
+ function deferred<T>() {
24
+ let resolve!: (value: T) => void;
25
+ const promise = new Promise<T>((r) => {
26
+ resolve = r;
27
+ });
28
+ return { promise, resolve };
29
+ }
30
+
31
+ const snapshot = (token: string): SessionSnapshot => ({
32
+ isLogged: true,
33
+ isAdministrator: false,
34
+ token,
35
+ sidebarOpen: true,
36
+ isMobile: false,
37
+ });
38
+
39
+ beforeEach(() => {
40
+ useSocketMock.mockClear();
41
+ });
42
+
43
+ describe("SocketProvider with a session", () => {
44
+ it("renders children before the session resolves", () => {
45
+ const pending = deferred<SessionSnapshot>();
46
+ render(
47
+ <SocketProvider session={pending.promise}>
48
+ <span>child</span>
49
+ </SocketProvider>,
50
+ );
51
+ expect(screen.getByText("child")).toBeInTheDocument();
52
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined });
53
+ });
54
+
55
+ it("connects with the token once the session resolves", async () => {
56
+ const pending = deferred<SessionSnapshot>();
57
+ render(
58
+ <SocketProvider session={pending.promise}>
59
+ <span>child</span>
60
+ </SocketProvider>,
61
+ );
62
+ await act(async () => {
63
+ pending.resolve(snapshot("t1"));
64
+ await pending.promise;
65
+ });
66
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1" });
67
+ });
68
+
69
+ it("still accepts a plain token", () => {
70
+ render(
71
+ <SocketProvider token="t2">
72
+ <span>child</span>
73
+ </SocketProvider>,
74
+ );
75
+ expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2" });
76
+ });
77
+ });
@@ -15,6 +15,7 @@ export * from "./HeaderChildrenContext";
15
15
  export * from "./HeaderLeftContentContext";
16
16
  export * from "./HeaderLogoContext";
17
17
  export * from "./MobileNavigationContext";
18
+ export * from "./SessionContext";
18
19
  export * from "./SharedContext";
19
20
  export * from "./SocketContext";
20
21
  export * from "./ViewportContext";
@@ -1,6 +1,7 @@
1
1
  import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInterface";
2
2
  import { ApiResponseInterface } from "../interfaces/ApiResponseInterface";
3
3
  import { setLastApiResponse } from "./lastApiResponse";
4
+ import { getI18nServerLocale } from "../../i18n/config";
4
5
 
5
6
  export enum HttpMethod {
6
7
  GET = "GET",
@@ -158,8 +159,7 @@ export abstract class AbstractService {
158
159
  // Get language based on environment
159
160
  let language = "en";
160
161
  if (typeof window === "undefined") {
161
- const { getLocale } = await import("next-intl/server");
162
- language = (await getLocale()) ?? "en";
162
+ language = await getI18nServerLocale();
163
163
  } else {
164
164
  // Client-side: extract locale from URL pathname
165
165
  language = this.getClientLocale();
@@ -279,8 +279,7 @@ export abstract class AbstractService {
279
279
  // Get language based on environment
280
280
  let language = "en";
281
281
  if (typeof window === "undefined") {
282
- const { getLocale } = await import("next-intl/server");
283
- language = (await getLocale()) ?? "en";
282
+ language = await getI18nServerLocale();
284
283
  } else {
285
284
  // Client-side: extract locale from URL pathname
286
285
  language = this.getClientLocale();
@@ -378,8 +377,7 @@ export abstract class AbstractService {
378
377
  let language = "en";
379
378
 
380
379
  if (typeof window === "undefined") {
381
- const { getLocale } = await import("next-intl/server");
382
- language = (await getLocale()) ?? "en";
380
+ language = await getI18nServerLocale();
383
381
  } else {
384
382
  // Client-side: extract locale from URL pathname
385
383
  language = this.getClientLocale();
@@ -0,0 +1,64 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // The library must not import next-intl itself: instrumentation loads the library, and
4
+ // next-intl's getLocale pulls in the app's i18n/request.ts, which reads `next/root-params`
5
+ // (only allowed inside the app directory). The locale comes from configureI18n instead.
6
+ const { nextIntlServer, jsonApiGet } = vi.hoisted(() => ({
7
+ nextIntlServer: vi.fn(),
8
+ jsonApiGet: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("next-intl/server", () => {
12
+ nextIntlServer();
13
+ return { getLocale: vi.fn(async () => "xx") };
14
+ });
15
+
16
+ vi.mock("../../../unified/JsonApiRequest", () => ({
17
+ JsonApiGet: jsonApiGet,
18
+ JsonApiPost: vi.fn(),
19
+ JsonApiPut: vi.fn(),
20
+ JsonApiPatch: vi.fn(),
21
+ JsonApiDelete: vi.fn(),
22
+ }));
23
+
24
+ import { configureI18n, type I18nConfig } from "../../../i18n/config";
25
+ import { AbstractService, HttpMethod } from "../AbstractService";
26
+
27
+ class TestService extends AbstractService {
28
+ static read() {
29
+ return this.callApi({ type: { name: "tests" } as any, method: HttpMethod.GET, endpoint: "https://api.test/tests" });
30
+ }
31
+ }
32
+
33
+ describe("AbstractService server locale", () => {
34
+ beforeEach(() => {
35
+ vi.stubGlobal("window", undefined);
36
+ jsonApiGet.mockResolvedValue({ ok: true, response: 200, data: [], included: [] });
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.unstubAllGlobals();
41
+ configureI18n(null as unknown as I18nConfig);
42
+ });
43
+
44
+ it("reads the server locale from configureI18n, never from next-intl", async () => {
45
+ configureI18n({
46
+ useRouter: vi.fn(),
47
+ useTranslations: vi.fn(),
48
+ Link: vi.fn() as unknown as I18nConfig["Link"],
49
+ usePathname: vi.fn(),
50
+ getLocale: async () => "it",
51
+ });
52
+
53
+ await TestService.read().catch(() => undefined);
54
+
55
+ expect(typeof window).toBe("undefined");
56
+ expect(jsonApiGet).toHaveBeenCalledWith(expect.objectContaining({ language: "it" }));
57
+ expect(nextIntlServer).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("fails loudly when configureI18n has no getLocale", async () => {
61
+ await expect(TestService.read()).rejects.toThrow(/configureI18n/);
62
+ expect(jsonApiGet).not.toHaveBeenCalled();
63
+ });
64
+ });
@@ -18,7 +18,11 @@ type Props = {
18
18
  deniedRedirect?: string;
19
19
  };
20
20
 
21
- /** Server page for the administrative analytics dashboard: role gate, then the client container. */
21
+ /**
22
+ * Server page for the administrative analytics dashboard: role gate, then the client container.
23
+ *
24
+ * Reads the session: render inside a `<Suspense>` boundary (Cache Components).
25
+ */
22
26
  export async function AnalyticsAdminPage({ adminRoleId, pageUrl, sessionPageUrl, deniedRedirect = "/" }: Props) {
23
27
  if (!(await ServerSession.hasRole(adminRoleId))) {
24
28
  redirect(deniedRedirect);