@carlonicora/nextjs-jsonapi 6.12.1 → 6.13.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 (255) hide show
  1. package/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
  2. package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
  3. package/dist/{BlockNoteEditor-N2NBYCPC.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
  4. package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-CBYKEXAQ.js → BlockNoteEditor-2VLPSEKS.js} +34 -22
  6. package/dist/BlockNoteEditor-2VLPSEKS.js.map +1 -0
  7. package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
  8. package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
  9. package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
  10. package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
  11. package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
  12. package/dist/JsonApiRequest-WISJZSDE.js +26 -0
  13. package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
  14. package/dist/billing/index.js +499 -494
  15. package/dist/billing/index.js.map +1 -1
  16. package/dist/billing/index.mjs +13 -8
  17. package/dist/billing/index.mjs.map +1 -1
  18. package/dist/chunk-27LQDGNP.mjs +317 -0
  19. package/dist/chunk-27LQDGNP.mjs.map +1 -0
  20. package/dist/{chunk-6NZKWIOR.js → chunk-2H342HNN.js} +956 -929
  21. package/dist/chunk-2H342HNN.js.map +1 -0
  22. package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
  23. package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
  24. package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
  25. package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
  26. package/dist/chunk-6JUCIWDC.mjs +570 -0
  27. package/dist/chunk-6JUCIWDC.mjs.map +1 -0
  28. package/dist/chunk-7DBJTNSU.mjs +1049 -0
  29. package/dist/chunk-7DBJTNSU.mjs.map +1 -0
  30. package/dist/chunk-7JZHPMGW.mjs +704 -0
  31. package/dist/chunk-7JZHPMGW.mjs.map +1 -0
  32. package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
  33. package/dist/chunk-7ZHTPFFT.js.map +1 -0
  34. package/dist/chunk-B6JGKDP2.js +214 -0
  35. package/dist/chunk-B6JGKDP2.js.map +1 -0
  36. package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
  37. package/dist/chunk-BZ5J2FIV.js +570 -0
  38. package/dist/chunk-BZ5J2FIV.js.map +1 -0
  39. package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
  40. package/dist/chunk-DOCIFE6A.mjs +212 -0
  41. package/dist/chunk-DOCIFE6A.mjs.map +1 -0
  42. package/dist/chunk-GB6DBFYY.js +25 -0
  43. package/dist/chunk-GB6DBFYY.js.map +1 -0
  44. package/dist/{chunk-6XJOQFCP.mjs → chunk-H4NLDX5G.mjs} +59 -32
  45. package/dist/chunk-H4NLDX5G.mjs.map +1 -0
  46. package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
  47. package/dist/chunk-JJMMT24D.mjs.map +1 -0
  48. package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
  49. package/dist/chunk-KMW7UNL2.js.map +1 -0
  50. package/dist/chunk-MHB36MYE.js +317 -0
  51. package/dist/chunk-MHB36MYE.js.map +1 -0
  52. package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
  53. package/dist/chunk-MOGEGWXW.js.map +1 -0
  54. package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
  55. package/dist/chunk-P445QGOC.mjs.map +1 -0
  56. package/dist/chunk-PV4Z5W6V.js +212 -0
  57. package/dist/chunk-PV4Z5W6V.js.map +1 -0
  58. package/dist/chunk-PXDUJ47N.js +1049 -0
  59. package/dist/chunk-PXDUJ47N.js.map +1 -0
  60. package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
  61. package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
  62. package/dist/chunk-W62MHDWM.js +704 -0
  63. package/dist/chunk-W62MHDWM.js.map +1 -0
  64. package/dist/chunk-XXP3TQHF.mjs +25 -0
  65. package/dist/chunk-XXP3TQHF.mjs.map +1 -0
  66. package/dist/chunk-YRKIFYOE.mjs +214 -0
  67. package/dist/chunk-YRKIFYOE.mjs.map +1 -0
  68. package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
  69. package/dist/chunk-Z4POQWFG.js.map +1 -0
  70. package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
  71. package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
  72. package/dist/client/index.d.mts +2 -2
  73. package/dist/client/index.d.ts +2 -2
  74. package/dist/client/index.js +14 -9
  75. package/dist/client/index.js.map +1 -1
  76. package/dist/client/index.mjs +24 -19
  77. package/dist/components/index.d.mts +7 -1
  78. package/dist/components/index.d.ts +7 -1
  79. package/dist/components/index.js +11 -7
  80. package/dist/components/index.js.map +1 -1
  81. package/dist/components/index.mjs +10 -6
  82. package/dist/contexts/index.d.mts +1 -1
  83. package/dist/contexts/index.d.ts +1 -1
  84. package/dist/contexts/index.js +11 -7
  85. package/dist/contexts/index.js.map +1 -1
  86. package/dist/contexts/index.mjs +10 -6
  87. package/dist/core/index.d.mts +128 -2
  88. package/dist/core/index.d.ts +128 -2
  89. package/dist/core/index.js +17 -4
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/index.mjs +22 -9
  92. package/dist/email-automations/index.js +101 -96
  93. package/dist/email-automations/index.js.map +1 -1
  94. package/dist/email-automations/index.mjs +13 -8
  95. package/dist/email-automations/index.mjs.map +1 -1
  96. package/dist/features/analytics/index.js +128 -123
  97. package/dist/features/analytics/index.js.map +1 -1
  98. package/dist/features/analytics/index.mjs +13 -8
  99. package/dist/features/analytics/index.mjs.map +1 -1
  100. package/dist/features/analytics/server.js +11 -8
  101. package/dist/features/analytics/server.js.map +1 -1
  102. package/dist/features/analytics/server.mjs +9 -6
  103. package/dist/features/analytics/server.mjs.map +1 -1
  104. package/dist/features/blog/index.js +158 -153
  105. package/dist/features/blog/index.js.map +1 -1
  106. package/dist/features/blog/index.mjs +14 -9
  107. package/dist/features/blog/index.mjs.map +1 -1
  108. package/dist/features/blog/server.js +30 -26
  109. package/dist/features/blog/server.js.map +1 -1
  110. package/dist/features/blog/server.mjs +10 -6
  111. package/dist/features/blog/server.mjs.map +1 -1
  112. package/dist/features/help/index.js +44 -40
  113. package/dist/features/help/index.js.map +1 -1
  114. package/dist/features/help/index.mjs +10 -6
  115. package/dist/features/help/index.mjs.map +1 -1
  116. package/dist/features/tokenusage/index.js +134 -129
  117. package/dist/features/tokenusage/index.js.map +1 -1
  118. package/dist/features/tokenusage/index.mjs +13 -8
  119. package/dist/features/tokenusage/index.mjs.map +1 -1
  120. package/dist/index.d.mts +2 -2
  121. package/dist/index.d.ts +2 -2
  122. package/dist/index.js +18 -5
  123. package/dist/index.js.map +1 -1
  124. package/dist/index.mjs +24 -11
  125. package/dist/lifecycle-4GUOTB46.js +23 -0
  126. package/dist/lifecycle-4GUOTB46.js.map +1 -0
  127. package/dist/lifecycle-LKGPRBS7.mjs +23 -0
  128. package/dist/local-transport-7H3SPZSA.js +15 -0
  129. package/dist/local-transport-7H3SPZSA.js.map +1 -0
  130. package/dist/local-transport-QYFKA6AN.mjs +15 -0
  131. package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
  132. package/dist/offline/index.d.mts +607 -0
  133. package/dist/offline/index.d.ts +607 -0
  134. package/dist/offline/index.js +1052 -0
  135. package/dist/offline/index.js.map +1 -0
  136. package/dist/offline/index.mjs +1052 -0
  137. package/dist/offline/index.mjs.map +1 -0
  138. package/dist/offline/sqlite/device-worker.mjs +62 -0
  139. package/dist/offline/sqlite/index.mjs +17392 -0
  140. package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
  141. package/dist/offline/sqlite/sqlite3.wasm +0 -0
  142. package/dist/request-KTBV6HHH.js +10 -0
  143. package/dist/request-KTBV6HHH.js.map +1 -0
  144. package/dist/request-SQOUNWZ4.mjs +10 -0
  145. package/dist/request-SQOUNWZ4.mjs.map +1 -0
  146. package/dist/server/cachedGet.d.mts +1 -1
  147. package/dist/server/cachedGet.d.ts +1 -1
  148. package/dist/server/cachedGet.stub.d.mts +1 -1
  149. package/dist/server/cachedGet.stub.d.ts +1 -1
  150. package/dist/server/index.d.mts +28 -2
  151. package/dist/server/index.d.ts +28 -2
  152. package/dist/server/index.js +18 -8
  153. package/dist/server/index.js.map +1 -1
  154. package/dist/server/index.mjs +18 -8
  155. package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
  156. package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
  157. package/dist/write-through-FDQ7N4YI.js +15 -0
  158. package/dist/write-through-FDQ7N4YI.js.map +1 -0
  159. package/dist/write-through-R25VQMH6.mjs +15 -0
  160. package/dist/write-through-R25VQMH6.mjs.map +1 -0
  161. package/package.json +7 -1
  162. package/pwa/sw.js +277 -26
  163. package/src/client/request.ts +37 -0
  164. package/src/components/containers/RoundPageContainer.tsx +8 -8
  165. package/src/components/editors/BlockNoteEditor.tsx +14 -0
  166. package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
  167. package/src/contexts/SocketContext.tsx +7 -1
  168. package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
  169. package/src/core/factories/RehydrationFactory.ts +28 -8
  170. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
  171. package/src/core/index.ts +3 -0
  172. package/src/core/interfaces/ApiData.ts +2 -0
  173. package/src/core/registry/ModuleRegistry.ts +3 -0
  174. package/src/features/auth/components/forms/Logout.tsx +8 -1
  175. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
  176. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  177. package/src/hooks/useSocket.ts +20 -1
  178. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  179. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  180. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  181. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  182. package/src/offline/__tests__/logout.spec.ts +122 -0
  183. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  184. package/src/offline/__tests__/router.spec.ts +56 -0
  185. package/src/offline/__tests__/state.spec.ts +62 -0
  186. package/src/offline/__tests__/write-through.spec.ts +137 -0
  187. package/src/offline/config.ts +229 -0
  188. package/src/offline/configure.ts +14 -0
  189. package/src/offline/device-switch.ts +75 -0
  190. package/src/offline/generic-answers.ts +706 -0
  191. package/src/offline/index.ts +67 -0
  192. package/src/offline/local-transport.ts +189 -0
  193. package/src/offline/logout.ts +41 -0
  194. package/src/offline/router.ts +41 -0
  195. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  196. package/src/offline/server/build-offline-manifest.ts +49 -0
  197. package/src/offline/state-hooks.ts +10 -0
  198. package/src/offline/state.ts +143 -0
  199. package/src/offline/store/DeviceStore.ts +112 -0
  200. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  201. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  202. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  203. package/src/offline/store/lifecycle.ts +315 -0
  204. package/src/offline/store/schema.ts +101 -0
  205. package/src/offline/store/shared-database.ts +321 -0
  206. package/src/offline/store/sql-device-store.ts +469 -0
  207. package/src/offline/store/worker-client.ts +71 -0
  208. package/src/offline/store/worker.ts +87 -0
  209. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  210. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  211. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  212. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  213. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  214. package/src/offline/sync/artwork-cache.ts +67 -0
  215. package/src/offline/sync/conflicts.ts +211 -0
  216. package/src/offline/sync/counts.ts +39 -0
  217. package/src/offline/sync/drain-queue.ts +246 -0
  218. package/src/offline/sync/known-roots.ts +37 -0
  219. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  220. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  221. package/src/offline/sync/models/RootSync.ts +68 -0
  222. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  223. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  224. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  225. package/src/offline/sync/models/index.ts +5 -0
  226. package/src/offline/sync/models/offline.modules.ts +14 -0
  227. package/src/offline/sync/runtime.ts +226 -0
  228. package/src/offline/sync/session-refresh.ts +32 -0
  229. package/src/offline/sync/sync-root.ts +336 -0
  230. package/src/offline/sync/waiting.ts +40 -0
  231. package/src/offline/types.ts +115 -0
  232. package/src/offline/write-through.ts +189 -0
  233. package/src/pwa/__tests__/sw.spec.ts +358 -1
  234. package/src/server/index.ts +5 -0
  235. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  236. package/dist/BlockNoteEditor-CBYKEXAQ.js.map +0 -1
  237. package/dist/BlockNoteEditor-N2NBYCPC.mjs.map +0 -1
  238. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  239. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  240. package/dist/chunk-6NZKWIOR.js.map +0 -1
  241. package/dist/chunk-6XJOQFCP.mjs.map +0 -1
  242. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  243. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  244. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  245. package/dist/chunk-JTPRJNT2.js.map +0 -1
  246. package/dist/chunk-L6PYFQTO.js.map +0 -1
  247. package/dist/chunk-O3PDL2CV.js.map +0 -1
  248. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  249. package/dist/request-5RLK2AM3.mjs +0 -9
  250. package/dist/request-EUGTI3BE.js +0 -9
  251. package/dist/request-EUGTI3BE.js.map +0 -1
  252. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  253. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  254. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  255. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,205 @@
1
+ import { act, render } from "@testing-library/react";
2
+ import React from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * BlockNote emits one change when an empty editor mounts (it inserts its trailing empty
7
+ * paragraph). Reported to the parent, that no-op became a save on every page view (an autosave
8
+ * of an empty field), and offline a queued change. The editor reports a change unless the text
9
+ * went from empty to still empty. Mocks as in BlockNoteEditor.ai.spec.tsx, plus the view's
10
+ * onChange and the editor's replaceBlocks.
11
+ */
12
+ const h = vi.hoisted(() => {
13
+ const AIExtension = vi.fn((options: unknown) => ({ aiFactory: true, options }));
14
+ return {
15
+ AIExtension,
16
+ editorsCreated: 0,
17
+ company: undefined as { id: string } | undefined,
18
+ editor: undefined as any,
19
+ ai: undefined as any,
20
+ transportConfig: undefined as any,
21
+ viewOnChange: undefined as undefined | (() => void),
22
+ };
23
+ });
24
+
25
+ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
26
+ vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
27
+ vi.mock("../../../i18n/config", () => ({ useI18nLocale: () => "en" }));
28
+ vi.mock("../../../client/config", () => ({ getPublicApiUrl: () => "http://api.test/" }));
29
+ vi.mock("../../../client/token", () => ({ getClientToken: async () => "token" }));
30
+ vi.mock("../../../contexts", () => ({
31
+ useCurrentUserContextOptional: () => (h.company ? { company: h.company } : undefined),
32
+ }));
33
+
34
+ vi.mock("@blocknote/react", async () => {
35
+ const React = await import("react");
36
+ const EditorContext = React.createContext<any>(null);
37
+ const useBlockNoteEditor = () => React.useContext(EditorContext);
38
+ const requireAI = (editor: any) => {
39
+ if (!editor?.getExtension(h.AIExtension)) throw new Error("Extension not found");
40
+ };
41
+ return {
42
+ EditorContext,
43
+ createReactInlineContentSpec: (config: unknown) => ({ config }),
44
+ getDefaultReactSlashMenuItems: () => [],
45
+ SuggestionMenuController: () => null,
46
+ useBlockNoteEditor,
47
+ useExtension: () => {
48
+ const editor = useBlockNoteEditor();
49
+ requireAI(editor);
50
+ return editor.getExtension(h.AIExtension);
51
+ },
52
+ useExtensionState: () => "closed",
53
+ useCreateBlockNote: (options: any, deps: React.DependencyList = []) =>
54
+ React.useMemo(() => {
55
+ h.editorsCreated += 1;
56
+ const extensions: any[] = options?.extensions ?? [];
57
+ // Mirrors xl-ai 0.55: a store holding aiMenuState, and the calls that
58
+ // move it back to "closed".
59
+ const store = { state: { aiMenuState: "closed" as any } };
60
+ const ai = {
61
+ store,
62
+ invokeAI: vi.fn(),
63
+ openAIMenuAtBlock: vi.fn((blockId: string) => {
64
+ store.state.aiMenuState = { blockId, status: "user-input" };
65
+ }),
66
+ abort: vi.fn(async () => {
67
+ const menu = store.state.aiMenuState;
68
+ if (menu !== "closed") store.state.aiMenuState = { ...menu, status: "user-reviewing" };
69
+ }),
70
+ closeAIMenu: vi.fn(() => {
71
+ store.state.aiMenuState = "closed";
72
+ }),
73
+ rejectChanges: vi.fn(() => {
74
+ store.state.aiMenuState = "closed";
75
+ }),
76
+ };
77
+ const hasAI = extensions.some((e) => e?.aiFactory);
78
+ const editor = {
79
+ document: [] as any[],
80
+ replaceBlocks: (_old: unknown, blocks: any[]) => {
81
+ editor.document = blocks;
82
+ },
83
+ focus: () => undefined,
84
+ getSelection: () => undefined,
85
+ getExtension: (factory: unknown) => (factory === h.AIExtension && hasAI ? ai : undefined),
86
+ };
87
+ h.editor = editor;
88
+ h.ai = hasAI ? ai : undefined;
89
+ return editor;
90
+ }, deps),
91
+ BasicTextStyleButton: () => null,
92
+ BlockTypeSelect: () => null,
93
+ CreateLinkButton: () => null,
94
+ FileCaptionButton: () => null,
95
+ FileReplaceButton: () => null,
96
+ TextAlignButton: () => null,
97
+ FormattingToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
98
+ FormattingToolbarController: ({ formattingToolbar }: { formattingToolbar: () => React.ReactNode }) => (
99
+ <>{formattingToolbar()}</>
100
+ ),
101
+ // Shared with the xl-ai stubs below.
102
+ __requireAI: requireAI,
103
+ };
104
+ });
105
+
106
+ vi.mock("@blocknote/shadcn", async () => {
107
+ const { EditorContext } = (await import("@blocknote/react")) as any;
108
+ return {
109
+ BlockNoteView: ({
110
+ editor,
111
+ children,
112
+ onChange,
113
+ }: {
114
+ editor: unknown;
115
+ children: React.ReactNode;
116
+ onChange?: () => void;
117
+ }) => {
118
+ h.viewOnChange = onChange;
119
+ return <EditorContext.Provider value={editor}>{children}</EditorContext.Provider>;
120
+ },
121
+ };
122
+ });
123
+
124
+ vi.mock("@blocknote/xl-ai", async () => {
125
+ const { useBlockNoteEditor, __requireAI } = (await import("@blocknote/react")) as any;
126
+ return {
127
+ AIExtension: h.AIExtension,
128
+ AIMenuController: () => {
129
+ __requireAI(useBlockNoteEditor());
130
+ return <div data-testid="ai-menu" />;
131
+ },
132
+ AIToolbarButton: () => {
133
+ __requireAI(useBlockNoteEditor());
134
+ return <div data-testid="ai-toolbar-button" />;
135
+ },
136
+ getAISlashMenuItems: () => [],
137
+ getDefaultAIMenuItems: () => [],
138
+ PromptSuggestionMenu: () => null,
139
+ useAIDictionary: () => ({}),
140
+ };
141
+ });
142
+
143
+ vi.mock("ai", () => ({
144
+ DefaultChatTransport: function (this: any, config: unknown) {
145
+ h.transportConfig = config;
146
+ },
147
+ }));
148
+ vi.mock("@blocknote/core", () => ({
149
+ BlockNoteSchema: { create: (config: unknown) => ({ config }) },
150
+ defaultInlineContentSpecs: {},
151
+ filterSuggestionItems: (items: unknown[]) => items,
152
+ }));
153
+ vi.mock("@blocknote/core/locales", () => ({ en: { core: "en" } }));
154
+ vi.mock("@blocknote/xl-ai/locales", () => ({ en: { ai: "en" } }));
155
+ vi.mock("@blocknote/shadcn/style.css", () => ({}));
156
+ vi.mock("@blocknote/xl-ai/style.css", () => ({}));
157
+ vi.mock("../BlockNoteEditorMentionInlineContent", () => ({ createMentionInlineContentSpec: () => ({}) }));
158
+
159
+ import BlockNoteEditor from "../BlockNoteEditor";
160
+
161
+ const emptyParagraph = () => [{ id: "p0", type: "paragraph", props: {}, content: [], children: [] }];
162
+ const textParagraph = (text: string) => [
163
+ { id: "p1", type: "paragraph", props: {}, content: [{ type: "text", text, styles: {} }], children: [] },
164
+ ];
165
+
166
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
167
+
168
+ async function emit(document: any[]): Promise<void> {
169
+ h.editor.document = document;
170
+ await act(async () => {
171
+ h.viewOnChange?.();
172
+ await flush();
173
+ });
174
+ }
175
+
176
+ describe("BlockNoteEditor change reports", () => {
177
+ const onChange = vi.fn();
178
+
179
+ beforeEach(() => {
180
+ onChange.mockReset();
181
+ h.viewOnChange = undefined;
182
+ });
183
+
184
+ it("does not report the change an empty editor emits when it mounts (empty to still empty)", async () => {
185
+ render(<BlockNoteEditor id="npc-1" type="npcs" onChange={onChange} />);
186
+ await emit(emptyParagraph());
187
+ expect(onChange).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it("reports typing into an empty editor, then clearing it again", async () => {
191
+ render(<BlockNoteEditor id="npc-1" type="npcs" onChange={onChange} />);
192
+ await emit(textParagraph("Hello"));
193
+ expect(onChange).toHaveBeenLastCalledWith(textParagraph("Hello"), false, false);
194
+ await emit(emptyParagraph());
195
+ expect(onChange).toHaveBeenLastCalledWith(emptyParagraph(), true, false);
196
+ expect(onChange).toHaveBeenCalledTimes(2);
197
+ });
198
+
199
+ it("reports clearing an editor that started with text, even as its first change", async () => {
200
+ render(<BlockNoteEditor id="npc-1" type="npcs" initialContent={textParagraph("Old") as any} onChange={onChange} />);
201
+ await emit(emptyParagraph());
202
+ expect(onChange).toHaveBeenCalledTimes(1);
203
+ expect(onChange).toHaveBeenLastCalledWith(emptyParagraph(), true, false);
204
+ });
205
+ });
@@ -3,6 +3,8 @@
3
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 { isOfflineConfigured } from "../offline/config";
7
+ import { useOfflineState } from "../offline/state-hooks";
6
8
  import type { SessionSnapshot } from "../server/ServerSession";
7
9
 
8
10
  interface SocketContextProps {
@@ -62,8 +64,12 @@ export const SocketProvider: React.FC<SocketProviderProps> = ({ token, session,
62
64
  }, [session]);
63
65
 
64
66
  const resolvedToken = token ?? sessionToken;
67
+ // Offline mode (opt-in): the socket stops retrying while the session is offline and connects
68
+ // again once it is back online. Without offline mode the state stays online: never paused.
69
+ const offlineState = useOfflineState();
70
+ const paused = isOfflineConfigured() && !offlineState.online;
65
71
  // Called unconditionally: useSocket returns early while the token is falsy.
66
- const socketHookData = useSocket({ token: resolvedToken as string });
72
+ const socketHookData = useSocket({ token: resolvedToken as string, paused });
67
73
 
68
74
  const sendMessage = (event: string, data: any) => {
69
75
  const currentSocket = socketHookData ? socketHookData.socket : defaultContextValue.socket;
@@ -1,9 +1,9 @@
1
1
  import { act, render, screen } from "@testing-library/react";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import type { SessionSnapshot } from "../../server/ServerSession";
4
4
 
5
5
  const { useSocketMock } = vi.hoisted(() => ({
6
- useSocketMock: vi.fn((_options: { token?: string }) => ({
6
+ useSocketMock: vi.fn((_options: { token?: string; paused?: boolean }) => ({
7
7
  socket: null,
8
8
  isConnected: false,
9
9
  messages: [],
@@ -18,6 +18,9 @@ const { useSocketMock } = vi.hoisted(() => ({
18
18
 
19
19
  vi.mock("../../hooks/useSocket", () => ({ useSocket: useSocketMock }));
20
20
 
21
+ import { configureOffline, resetOfflineConfig } from "../../offline/config";
22
+ import { resetOfflineState, setOfflineState } from "../../offline/state";
23
+ import type { OfflineConfig } from "../../offline/types";
21
24
  import { SocketProvider } from "../SocketContext";
22
25
 
23
26
  function deferred<T>() {
@@ -49,7 +52,7 @@ describe("SocketProvider with a session", () => {
49
52
  </SocketProvider>,
50
53
  );
51
54
  expect(screen.getByText("child")).toBeInTheDocument();
52
- expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined });
55
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined, paused: false });
53
56
  });
54
57
 
55
58
  it("connects with the token once the session resolves", async () => {
@@ -63,7 +66,7 @@ describe("SocketProvider with a session", () => {
63
66
  pending.resolve(snapshot("t1"));
64
67
  await pending.promise;
65
68
  });
66
- expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1" });
69
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1", paused: false });
67
70
  });
68
71
 
69
72
  it("still accepts a plain token", () => {
@@ -72,6 +75,50 @@ describe("SocketProvider with a session", () => {
72
75
  <span>child</span>
73
76
  </SocketProvider>,
74
77
  );
75
- expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2" });
78
+ expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2", paused: false });
79
+ });
80
+ });
81
+
82
+ describe("SocketProvider and offline mode", () => {
83
+ const offlineConfig = {
84
+ getUserId: () => "u1",
85
+ rootModule: { name: "roots" },
86
+ types: [],
87
+ routes: [],
88
+ listSyncRoots: async () => [],
89
+ shellPath: () => "/offline-shell",
90
+ syncIntervalMs: 300000,
91
+ } as unknown as OfflineConfig;
92
+
93
+ afterEach(() => {
94
+ resetOfflineConfig();
95
+ resetOfflineState();
96
+ });
97
+
98
+ it("with offline mode configured, pauses the socket while the session is offline and resumes it when back online", () => {
99
+ configureOffline(offlineConfig);
100
+ setOfflineState({ online: true });
101
+ render(
102
+ <SocketProvider token="t3">
103
+ <span>child</span>
104
+ </SocketProvider>,
105
+ );
106
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: false });
107
+
108
+ act(() => setOfflineState({ online: false }));
109
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: true });
110
+
111
+ act(() => setOfflineState({ online: true }));
112
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: false });
113
+ });
114
+
115
+ it("without offline mode configured, never pauses the socket", () => {
116
+ setOfflineState({ online: false });
117
+ render(
118
+ <SocketProvider token="t4">
119
+ <span>child</span>
120
+ </SocketProvider>,
121
+ );
122
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t4", paused: false });
76
123
  });
77
124
  });
@@ -3,6 +3,30 @@ import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInt
3
3
  import { JsonApiHydratedDataInterface } from "../interfaces/JsonApiHydratedDataInterface";
4
4
  import { DataClassRegistry } from "../registry/DataClassRegistry";
5
5
 
6
+ /**
7
+ * The resources being rehydrated right now, along the current chain of relationships. A resource
8
+ * met again inside its own chain (A's relationship leads to B, B's back to A) is a circle in the
9
+ * `included` data: following it would recurse until the stack overflows, so it is skipped there.
10
+ */
11
+ const inProgress = new Set<string>();
12
+
13
+ const keyOf = (data: JsonApiHydratedDataInterface): string | undefined => {
14
+ const resource = data?.jsonApi;
15
+ return resource?.type && resource?.id ? `${resource.type}:${resource.id}` : undefined;
16
+ };
17
+
18
+ function guarded<T>(data: JsonApiHydratedDataInterface, run: () => T): T {
19
+ const key = keyOf(data);
20
+ if (!key) return run();
21
+ if (inProgress.has(key)) return undefined as T;
22
+ inProgress.add(key);
23
+ try {
24
+ return run();
25
+ } finally {
26
+ inProgress.delete(key);
27
+ }
28
+ }
29
+
6
30
  export class RehydrationFactory {
7
31
  public static rehydrate<T extends ApiDataInterface>(
8
32
  classKey: ApiRequestDataTypeInterface,
@@ -10,8 +34,7 @@ export class RehydrationFactory {
10
34
  ): T {
11
35
  const factoryClass = DataClassRegistry.get(classKey);
12
36
 
13
- const instance = new factoryClass();
14
- return instance.rehydrate(data) as T;
37
+ return guarded(data, () => new factoryClass().rehydrate(data) as T);
15
38
  }
16
39
 
17
40
  public static rehydrateList<T extends ApiDataInterface>(
@@ -20,11 +43,8 @@ export class RehydrationFactory {
20
43
  ): T[] {
21
44
  const factoryClass = DataClassRegistry.get(classKey);
22
45
 
23
- const response = data.map((item: JsonApiHydratedDataInterface) => {
24
- const instance = new factoryClass();
25
- return instance.rehydrate(item) as T;
26
- });
27
-
28
- return response;
46
+ return data.map((item: JsonApiHydratedDataInterface) =>
47
+ guarded(item, () => new factoryClass().rehydrate(item) as T),
48
+ );
29
49
  }
30
50
  }
@@ -149,4 +149,37 @@ describe("RehydrationFactory", () => {
149
149
  expect(results[0]).not.toBe(results[1]);
150
150
  });
151
151
  });
152
+
153
+ describe("circular included data", () => {
154
+ // A model that reads its "other" relationship from included, as AbstractApiData models do.
155
+ class Linked implements Partial<ApiDataInterface> {
156
+ id = "";
157
+ other?: Linked;
158
+ rehydrate(data: JsonApiHydratedDataInterface) {
159
+ this.id = data.jsonApi.id;
160
+ const ref = data.jsonApi.relationships?.other?.data;
161
+ const target = ref && data.included.find((item: any) => item.type === ref.type && item.id === ref.id);
162
+ if (target)
163
+ this.other = RehydrationFactory.rehydrate(linkedModule as any, { jsonApi: target, included: data.included });
164
+ return this;
165
+ }
166
+ }
167
+ const linkedModule = { name: "linked", model: Linked };
168
+
169
+ it("a relationship that leads back to a resource already being rehydrated is skipped, not followed forever", () => {
170
+ DataClassRegistry.registerObjectClass(linkedModule as any, Linked as any);
171
+ const a = { type: "linked", id: "a", relationships: { other: { data: { type: "linked", id: "b" } } } };
172
+ const b = { type: "linked", id: "b", relationships: { other: { data: { type: "linked", id: "a" } } } };
173
+
174
+ const result = RehydrationFactory.rehydrate<any>(linkedModule as any, { jsonApi: a, included: [a, b] });
175
+
176
+ expect(result.id).toBe("a");
177
+ expect(result.other.id).toBe("b");
178
+ expect(result.other.other).toBeUndefined();
179
+ // The guard is per chain: the same resource rehydrates again in a new call.
180
+ expect(RehydrationFactory.rehydrate<any>(linkedModule as any, { jsonApi: b, included: [a, b] }).other.id).toBe(
181
+ "a",
182
+ );
183
+ });
184
+ });
152
185
  });
package/src/core/index.ts CHANGED
@@ -131,6 +131,9 @@ export * from "../features/referral/referral.module";
131
131
  export * from "../features/referral/referral-stats.module";
132
132
  export * from "../features/referral/referral-admin.modules";
133
133
  export * from "../features/referral/interfaces/referral-admin.interface";
134
+ // Offline mode models and their module bundle (opt-in): exported from core so a
135
+ // bootstrapper can spread offlineModules without loading the ./offline entry.
136
+ export * from "../offline/sync/models";
134
137
  // Administrative token-usage reporting modules. Exported from core (not only
135
138
  // from the client-marked ./tokenusage barrel) so a consuming app can register
136
139
  // them in its bootstrapper without pulling the feature's client bundle —
@@ -5,4 +5,6 @@ export type ApiData = {
5
5
  statusText: string;
6
6
  token?: string;
7
7
  refreshToken?: string;
8
+ /** Set by `directFetch` when the API could not be reached at all (the 503 it reports). */
9
+ networkError?: boolean;
8
10
  };
@@ -107,6 +107,9 @@ export interface FoundationModuleDefinitions {
107
107
  AnalyticsPageView: ModuleWithPermissions;
108
108
  // Account deletion (opt-in: registered only by apps that enable it)
109
109
  AccountDeletion: ModuleWithPermissions;
110
+ // Offline mode (opt-in: registered by apps that spread offlineModules)
111
+ OfflineChangeSet: ModuleWithPermissions;
112
+ RootSync: ModuleWithPermissions;
110
113
  }
111
114
 
112
115
  // App-specific modules - apps will augment this interface ONLY
@@ -12,13 +12,20 @@ interface LogoutProps {
12
12
  * library's own `user` key is always cleared and does not need listing.
13
13
  */
14
14
  storageKeys?: string[];
15
+ /**
16
+ * Awaited before anything is cleared (offline mode: warn about changes that
17
+ * were not sent, then drop the device copy). Returning `false` cancels the
18
+ * logout: storage, cookies and the current page are left as they are.
19
+ */
20
+ beforeLogout?: () => Promise<boolean>;
15
21
  }
16
22
 
17
- export function Logout({ storageKeys }: LogoutProps) {
23
+ export function Logout({ storageKeys, beforeLogout }: LogoutProps) {
18
24
  const generateUrl = usePageUrlGenerator();
19
25
 
20
26
  useEffect(() => {
21
27
  const logOut = async () => {
28
+ if (beforeLogout && !(await beforeLogout())) return;
22
29
  // `CurrentUserProvider` persists the user in localStorage, so clearing the
23
30
  // cookies alone leaves the client believing the session is still alive:
24
31
  // the server renders as anonymous while every `useCurrentUserContext()`
@@ -120,4 +120,66 @@ describe("Logout", () => {
120
120
  });
121
121
  });
122
122
  });
123
+
124
+ // Offline mode: the app may hold unsent changes on the device, so it gets a
125
+ // chance to warn (or cancel) before anything is cleared.
126
+ describe("Scenario: beforeLogout hook", () => {
127
+ it("awaits beforeLogout and stops when it returns false", async () => {
128
+ let resolveHook: (value: boolean) => void = () => {};
129
+ const beforeLogout = vi.fn(
130
+ () =>
131
+ new Promise<boolean>((resolve) => {
132
+ resolveHook = resolve;
133
+ }),
134
+ );
135
+
136
+ render(<Logout beforeLogout={beforeLogout} />);
137
+
138
+ await waitFor(() => {
139
+ expect(beforeLogout).toHaveBeenCalledTimes(1);
140
+ });
141
+ expect(clearClientStorage).not.toHaveBeenCalled();
142
+ expect(AuthService.logout).not.toHaveBeenCalled();
143
+
144
+ resolveHook(false);
145
+ await new Promise((resolve) => setTimeout(resolve, 0));
146
+
147
+ expect(clearClientStorage).not.toHaveBeenCalled();
148
+ expect(AuthService.logout).not.toHaveBeenCalled();
149
+ expect(windowLocationHref).toBe("");
150
+ });
151
+
152
+ it("clears storage and logs out when it returns true or is absent", async () => {
153
+ const callOrder: string[] = [];
154
+ const beforeLogout = vi.fn(async () => {
155
+ callOrder.push("beforeLogout");
156
+ return true;
157
+ });
158
+ vi.mocked(clearClientStorage).mockImplementation(() => {
159
+ callOrder.push("clearClientStorage");
160
+ });
161
+ vi.mocked(AuthService.logout).mockImplementation(async () => {
162
+ callOrder.push("AuthService.logout");
163
+ });
164
+
165
+ const { unmount } = render(<Logout beforeLogout={beforeLogout} storageKeys={["person"]} />);
166
+
167
+ await waitFor(() => {
168
+ expect(windowLocationHref).toBe("/");
169
+ });
170
+ expect(callOrder).toEqual(["beforeLogout", "clearClientStorage", "AuthService.logout"]);
171
+ expect(clearClientStorage).toHaveBeenCalledWith([CURRENT_USER_STORAGE_KEY, "person"]);
172
+ unmount();
173
+
174
+ vi.clearAllMocks();
175
+ windowLocationHref = "";
176
+ render(<Logout />);
177
+
178
+ await waitFor(() => {
179
+ expect(windowLocationHref).toBe("/");
180
+ });
181
+ expect(clearClientStorage).toHaveBeenCalledWith([CURRENT_USER_STORAGE_KEY]);
182
+ expect(AuthService.logout).toHaveBeenCalledTimes(1);
183
+ });
184
+ });
123
185
  });
@@ -0,0 +1,39 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const { io, fakeSocket } = vi.hoisted(() => {
5
+ const fakeSocket = {
6
+ id: undefined,
7
+ connected: true,
8
+ on: vi.fn(),
9
+ off: vi.fn(),
10
+ emit: vi.fn(),
11
+ connect: vi.fn(),
12
+ disconnect: vi.fn(),
13
+ removeAllListeners: vi.fn(),
14
+ };
15
+ return { fakeSocket, io: vi.fn(() => fakeSocket) };
16
+ });
17
+ vi.mock("socket.io-client", () => ({ default: io }));
18
+
19
+ import { useSocket } from "../useSocket";
20
+
21
+ describe("useSocket pause (offline mode)", () => {
22
+ it("disconnects while paused and connects the same socket again when resumed", () => {
23
+ const { rerender } = renderHook(({ paused }) => useSocket({ token: "t1", paused }), {
24
+ initialProps: { paused: false },
25
+ });
26
+ expect(io).toHaveBeenCalledTimes(1);
27
+ expect(fakeSocket.disconnect).not.toHaveBeenCalled();
28
+
29
+ rerender({ paused: true });
30
+ expect(fakeSocket.disconnect).toHaveBeenCalledTimes(1);
31
+ rerender({ paused: true });
32
+ expect(fakeSocket.disconnect).toHaveBeenCalledTimes(1);
33
+ expect(fakeSocket.connect).not.toHaveBeenCalled();
34
+
35
+ rerender({ paused: false });
36
+ expect(fakeSocket.connect).toHaveBeenCalledTimes(1);
37
+ expect(io).toHaveBeenCalledTimes(1);
38
+ });
39
+ });
@@ -10,6 +10,8 @@ type Socket = ReturnType<typeof io>;
10
10
 
11
11
  interface UseSocketOptions {
12
12
  token: string;
13
+ /** While true the socket is disconnected (no reconnect attempts); false connects it again. */
14
+ paused?: boolean;
13
15
  }
14
16
 
15
17
  interface SocketLike {
@@ -31,7 +33,7 @@ interface UseSocketReturn {
31
33
  clearSocketNotifications: () => void;
32
34
  }
33
35
 
34
- export function useSocket({ token }: UseSocketOptions): UseSocketReturn {
36
+ export function useSocket({ token, paused = false }: UseSocketOptions): UseSocketReturn {
35
37
  const _errorCount = useRef(0);
36
38
  const shouldConnect = useRef(true);
37
39
 
@@ -168,6 +170,23 @@ export function useSocket({ token }: UseSocketOptions): UseSocketReturn {
168
170
  };
169
171
  }, [token]);
170
172
 
173
+ // Paused (offline mode, the session is offline): a manual disconnect stops socket.io retrying;
174
+ // resuming connects the same socket again.
175
+ const pausedSocket = useRef<Socket | null>(null);
176
+ useEffect(() => {
177
+ const current = socketRef.current;
178
+ if (!current) return;
179
+ if (paused) {
180
+ if (pausedSocket.current !== current) {
181
+ current.disconnect();
182
+ pausedSocket.current = current;
183
+ }
184
+ } else if (pausedSocket.current === current) {
185
+ pausedSocket.current = null;
186
+ current.connect();
187
+ }
188
+ }, [paused, socket]);
189
+
171
190
  const sendMessage = (event: string, data: any) => {
172
191
  if (socketRef.current && isConnected) {
173
192
  socketRef.current.emit(event, data);