@multiplatform.one/frappe 7.10.0 → 7.15.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 (239) hide show
  1. package/README.md +59 -1
  2. package/dist/cjs/auth.native.js.map +1 -1
  3. package/dist/cjs/call.native.js.map +1 -1
  4. package/dist/cjs/collection.native.js.map +1 -1
  5. package/dist/cjs/common/types.native.js.map +1 -1
  6. package/dist/cjs/core/http.native.js.map +1 -1
  7. package/dist/cjs/core/types.native.js.map +1 -1
  8. package/dist/cjs/core/wire.native.js.map +1 -1
  9. package/dist/cjs/db.native.js.map +1 -1
  10. package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  11. package/dist/cjs/devtools/index.native.js.map +1 -1
  12. package/dist/cjs/devtools/plugin.native.js.map +1 -1
  13. package/dist/cjs/devtools/theme.native.js.map +1 -1
  14. package/dist/cjs/doctype.cjs +25 -43
  15. package/dist/cjs/doctype.native.js +27 -43
  16. package/dist/cjs/doctype.native.js.map +1 -1
  17. package/dist/cjs/file.native.js.map +1 -1
  18. package/dist/cjs/fixtureDb.native.js.map +1 -1
  19. package/dist/cjs/fixtures.cjs +3 -0
  20. package/dist/cjs/fixtures.native.js +3 -0
  21. package/dist/cjs/fixtures.native.js.map +1 -1
  22. package/dist/cjs/frappeApp.native.js.map +1 -1
  23. package/dist/cjs/frappeReact.cjs +67 -31
  24. package/dist/cjs/frappeReact.native.js +87 -34
  25. package/dist/cjs/frappeReact.native.js.map +1 -1
  26. package/dist/cjs/index.cjs +7 -0
  27. package/dist/cjs/index.native.js +9 -2
  28. package/dist/cjs/index.native.js.map +1 -1
  29. package/dist/cjs/optimistic.native.js.map +1 -1
  30. package/dist/cjs/pacer.native.js.map +1 -1
  31. package/dist/cjs/plugin/index.native.js.map +1 -1
  32. package/dist/cjs/realtime.native.js.map +1 -1
  33. package/dist/cjs/serverLoaders.cjs +74 -0
  34. package/dist/cjs/serverLoaders.native.js +88 -0
  35. package/dist/cjs/serverLoaders.native.js.map +1 -0
  36. package/dist/cjs/sync/backfill.native.js.map +1 -1
  37. package/dist/cjs/sync/filters.native.js.map +1 -1
  38. package/dist/cjs/sync/index.cjs +99 -14
  39. package/dist/cjs/sync/index.native.js +129 -14
  40. package/dist/cjs/sync/index.native.js.map +1 -1
  41. package/dist/cjs/sync/merge.native.js.map +1 -1
  42. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  43. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  44. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  45. package/dist/cjs/sync/persistence.native.js.map +1 -1
  46. package/dist/cjs/sync/realtime.native.js.map +1 -1
  47. package/dist/cjs/sync/rehydration.cjs +4 -0
  48. package/dist/cjs/sync/rehydration.native.js +6 -0
  49. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  50. package/dist/cjs/sync/storage.native.js.map +1 -1
  51. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  52. package/dist/cjs/sync/types.native.js.map +1 -1
  53. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  54. package/dist/cjs/syncOptionsKey.cjs +59 -0
  55. package/dist/cjs/syncOptionsKey.native.js +82 -0
  56. package/dist/cjs/syncOptionsKey.native.js.map +1 -0
  57. package/dist/cjs/useLiveQuery.cjs +2 -20
  58. package/dist/cjs/useLiveQuery.native.js +1 -22
  59. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  60. package/dist/cjs/util.native.js.map +1 -1
  61. package/dist/esm/auth.native.js.map +1 -1
  62. package/dist/esm/call.native.js.map +1 -1
  63. package/dist/esm/collection.native.js.map +1 -1
  64. package/dist/esm/common/types.native.js.map +1 -1
  65. package/dist/esm/core/http.native.js.map +1 -1
  66. package/dist/esm/core/types.native.js.map +1 -1
  67. package/dist/esm/core/wire.native.js.map +1 -1
  68. package/dist/esm/db.native.js.map +1 -1
  69. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  70. package/dist/esm/devtools/index.native.js.map +1 -1
  71. package/dist/esm/devtools/plugin.native.js.map +1 -1
  72. package/dist/esm/devtools/theme.native.js.map +1 -1
  73. package/dist/esm/doctype.mjs +23 -44
  74. package/dist/esm/doctype.mjs.map +1 -1
  75. package/dist/esm/doctype.native.js +25 -44
  76. package/dist/esm/doctype.native.js.map +1 -1
  77. package/dist/esm/file.native.js.map +1 -1
  78. package/dist/esm/fixtureDb.native.js.map +1 -1
  79. package/dist/esm/fixtures.mjs +3 -0
  80. package/dist/esm/fixtures.mjs.map +1 -1
  81. package/dist/esm/fixtures.native.js +3 -0
  82. package/dist/esm/fixtures.native.js.map +1 -1
  83. package/dist/esm/frappeApp.native.js.map +1 -1
  84. package/dist/esm/frappeReact.mjs +68 -33
  85. package/dist/esm/frappeReact.mjs.map +1 -1
  86. package/dist/esm/frappeReact.native.js +87 -35
  87. package/dist/esm/frappeReact.native.js.map +1 -1
  88. package/dist/esm/index.mjs +4 -3
  89. package/dist/esm/index.mjs.map +1 -1
  90. package/dist/esm/index.native.js +4 -3
  91. package/dist/esm/index.native.js.map +1 -1
  92. package/dist/esm/optimistic.native.js.map +1 -1
  93. package/dist/esm/pacer.native.js.map +1 -1
  94. package/dist/esm/plugin/index.native.js.map +1 -1
  95. package/dist/esm/realtime.native.js.map +1 -1
  96. package/dist/esm/serverLoaders.mjs +48 -0
  97. package/dist/esm/serverLoaders.mjs.map +1 -0
  98. package/dist/esm/serverLoaders.native.js +59 -0
  99. package/dist/esm/serverLoaders.native.js.map +1 -0
  100. package/dist/esm/sync/backfill.native.js.map +1 -1
  101. package/dist/esm/sync/filters.native.js.map +1 -1
  102. package/dist/esm/sync/index.mjs +99 -14
  103. package/dist/esm/sync/index.mjs.map +1 -1
  104. package/dist/esm/sync/index.native.js +129 -14
  105. package/dist/esm/sync/index.native.js.map +1 -1
  106. package/dist/esm/sync/merge.native.js.map +1 -1
  107. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  108. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  109. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  110. package/dist/esm/sync/persistence.native.js.map +1 -1
  111. package/dist/esm/sync/realtime.native.js.map +1 -1
  112. package/dist/esm/sync/rehydration.mjs +4 -0
  113. package/dist/esm/sync/rehydration.mjs.map +1 -1
  114. package/dist/esm/sync/rehydration.native.js +6 -0
  115. package/dist/esm/sync/rehydration.native.js.map +1 -1
  116. package/dist/esm/sync/storage.native.js.map +1 -1
  117. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  118. package/dist/esm/sync/types.mjs.map +1 -1
  119. package/dist/esm/sync/types.native.js.map +1 -1
  120. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  121. package/dist/esm/syncOptionsKey.mjs +34 -0
  122. package/dist/esm/syncOptionsKey.mjs.map +1 -0
  123. package/dist/esm/syncOptionsKey.native.js +54 -0
  124. package/dist/esm/syncOptionsKey.native.js.map +1 -0
  125. package/dist/esm/useLiveQuery.mjs +1 -19
  126. package/dist/esm/useLiveQuery.mjs.map +1 -1
  127. package/dist/esm/useLiveQuery.native.js +1 -22
  128. package/dist/esm/useLiveQuery.native.js.map +1 -1
  129. package/dist/esm/util.native.js.map +1 -1
  130. package/dist/jsx/auth.native.js.map +1 -1
  131. package/dist/jsx/call.native.js.map +1 -1
  132. package/dist/jsx/collection.native.js.map +1 -1
  133. package/dist/jsx/common/types.native.js.map +1 -1
  134. package/dist/jsx/core/http.native.js.map +1 -1
  135. package/dist/jsx/core/types.native.js.map +1 -1
  136. package/dist/jsx/core/wire.native.js.map +1 -1
  137. package/dist/jsx/db.native.js.map +1 -1
  138. package/dist/jsx/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  139. package/dist/jsx/devtools/index.native.js.map +1 -1
  140. package/dist/jsx/devtools/plugin.native.js.map +1 -1
  141. package/dist/jsx/devtools/theme.native.js.map +1 -1
  142. package/dist/jsx/doctype.mjs +23 -44
  143. package/dist/jsx/doctype.mjs.map +1 -1
  144. package/dist/jsx/doctype.native.js +27 -43
  145. package/dist/jsx/doctype.native.js.map +1 -1
  146. package/dist/jsx/file.native.js.map +1 -1
  147. package/dist/jsx/fixtureDb.native.js.map +1 -1
  148. package/dist/jsx/fixtures.mjs +3 -0
  149. package/dist/jsx/fixtures.mjs.map +1 -1
  150. package/dist/jsx/fixtures.native.js +3 -0
  151. package/dist/jsx/fixtures.native.js.map +1 -1
  152. package/dist/jsx/frappeApp.native.js.map +1 -1
  153. package/dist/jsx/frappeReact.mjs +68 -33
  154. package/dist/jsx/frappeReact.mjs.map +1 -1
  155. package/dist/jsx/frappeReact.native.js +87 -34
  156. package/dist/jsx/frappeReact.native.js.map +1 -1
  157. package/dist/jsx/index.js +4 -3
  158. package/dist/jsx/index.js.map +1 -1
  159. package/dist/jsx/index.mjs +4 -3
  160. package/dist/jsx/index.mjs.map +1 -1
  161. package/dist/jsx/index.native.js +9 -2
  162. package/dist/jsx/index.native.js.map +1 -1
  163. package/dist/jsx/optimistic.native.js.map +1 -1
  164. package/dist/jsx/pacer.native.js.map +1 -1
  165. package/dist/jsx/plugin/index.native.js.map +1 -1
  166. package/dist/jsx/realtime.native.js.map +1 -1
  167. package/dist/jsx/serverLoaders.mjs +48 -0
  168. package/dist/jsx/serverLoaders.mjs.map +1 -0
  169. package/dist/jsx/serverLoaders.native.js +88 -0
  170. package/dist/jsx/serverLoaders.native.js.map +1 -0
  171. package/dist/jsx/sync/backfill.native.js.map +1 -1
  172. package/dist/jsx/sync/filters.native.js.map +1 -1
  173. package/dist/jsx/sync/index.mjs +99 -14
  174. package/dist/jsx/sync/index.mjs.map +1 -1
  175. package/dist/jsx/sync/index.native.js +129 -14
  176. package/dist/jsx/sync/index.native.js.map +1 -1
  177. package/dist/jsx/sync/merge.native.js.map +1 -1
  178. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  179. package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
  180. package/dist/jsx/sync/normalizedCache.native.js.map +1 -1
  181. package/dist/jsx/sync/persistence.native.js.map +1 -1
  182. package/dist/jsx/sync/realtime.native.js.map +1 -1
  183. package/dist/jsx/sync/rehydration.mjs +4 -0
  184. package/dist/jsx/sync/rehydration.mjs.map +1 -1
  185. package/dist/jsx/sync/rehydration.native.js +6 -0
  186. package/dist/jsx/sync/rehydration.native.js.map +1 -1
  187. package/dist/jsx/sync/storage.native.js.map +1 -1
  188. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  189. package/dist/jsx/sync/types.mjs.map +1 -1
  190. package/dist/jsx/sync/types.native.js.map +1 -1
  191. package/dist/jsx/sync/virtualPageManager.native.js.map +1 -1
  192. package/dist/jsx/syncOptionsKey.mjs +34 -0
  193. package/dist/jsx/syncOptionsKey.mjs.map +1 -0
  194. package/dist/jsx/syncOptionsKey.native.js +82 -0
  195. package/dist/jsx/syncOptionsKey.native.js.map +1 -0
  196. package/dist/jsx/useLiveQuery.mjs +1 -19
  197. package/dist/jsx/useLiveQuery.mjs.map +1 -1
  198. package/dist/jsx/useLiveQuery.native.js +1 -22
  199. package/dist/jsx/useLiveQuery.native.js.map +1 -1
  200. package/dist/jsx/util.native.js.map +1 -1
  201. package/package.json +12 -12
  202. package/src/doctype.spec.ts +148 -5
  203. package/src/doctype.ts +202 -173
  204. package/src/fixtures.ts +1 -0
  205. package/src/frappeReact.catchUp.spec.tsx +105 -0
  206. package/src/frappeReact.hydration.spec.tsx +164 -0
  207. package/src/frappeReact.spec.ts +56 -0
  208. package/src/frappeReact.ts +93 -43
  209. package/src/index.ts +14 -0
  210. package/src/serverLoaders.ts +77 -0
  211. package/src/sync/catchUp.spec.ts +142 -0
  212. package/src/sync/index.ts +106 -9
  213. package/src/sync/rehydration.ts +5 -0
  214. package/src/sync/syncModule.spec.ts +20 -0
  215. package/src/sync/types.ts +9 -1
  216. package/src/syncOptionsKey.spec.ts +46 -0
  217. package/src/syncOptionsKey.ts +45 -0
  218. package/src/useLiveQuery.hydration.spec.tsx +129 -0
  219. package/src/useLiveQuery.spec.ts +3 -11
  220. package/src/useLiveQuery.ts +3 -57
  221. package/types/doctype.d.ts +117 -33
  222. package/types/doctype.d.ts.map +1 -1
  223. package/types/fixtures.d.ts.map +1 -1
  224. package/types/frappeReact.d.ts +9 -3
  225. package/types/frappeReact.d.ts.map +1 -1
  226. package/types/index.d.ts +6 -5
  227. package/types/index.d.ts.map +1 -1
  228. package/types/serverLoaders.d.ts +40 -0
  229. package/types/serverLoaders.d.ts.map +1 -0
  230. package/types/sync/index.d.ts +15 -1
  231. package/types/sync/index.d.ts.map +1 -1
  232. package/types/sync/rehydration.d.ts +2 -0
  233. package/types/sync/rehydration.d.ts.map +1 -1
  234. package/types/sync/types.d.ts +8 -1
  235. package/types/sync/types.d.ts.map +1 -1
  236. package/types/syncOptionsKey.d.ts +3 -0
  237. package/types/syncOptionsKey.d.ts.map +1 -0
  238. package/types/useLiveQuery.d.ts +3 -13
  239. package/types/useLiveQuery.d.ts.map +1 -1
@@ -0,0 +1,164 @@
1
+ import { act, Suspense } from "react";
2
+ import { createRoot, hydrateRoot, type Root } from "react-dom/client";
3
+ import { renderToString } from "react-dom/server";
4
+ import { expect, it, vi } from "vitest";
5
+
6
+ const runtime = vi.hoisted(() => ({ client: false }));
7
+ const { mockSubscribe, mockUnsubscribe, mockGetDocsForSubscription, storeListeners, docStore } =
8
+ vi.hoisted(() => {
9
+ const listeners = new Set<() => void>();
10
+ const docs = new Map<string, Array<{ name: string; doctype: string; title: string }>>();
11
+ let subscriptionCounter = 0;
12
+ return {
13
+ storeListeners: listeners,
14
+ docStore: docs,
15
+ mockSubscribe: vi.fn(async (query: { doctype: string }) => {
16
+ const subscriptionId = `sub-${++subscriptionCounter}`;
17
+ docs.set(subscriptionId, [
18
+ { name: "ws-1", doctype: query.doctype, title: "Workspace One" },
19
+ { name: "ws-2", doctype: query.doctype, title: "Workspace Two" },
20
+ ]);
21
+ return { subscriptionId, rows: docs.get(subscriptionId), checkpoint: "t0" };
22
+ }),
23
+ mockUnsubscribe: vi.fn((subscriptionId: string) => {
24
+ docs.delete(subscriptionId);
25
+ }),
26
+ mockGetDocsForSubscription: vi.fn((subscriptionId: string) => {
27
+ return docs.get(subscriptionId) ?? [];
28
+ }),
29
+ };
30
+ });
31
+
32
+ vi.mock("./sync", () => ({
33
+ SyncModule: class {
34
+ subscribe = mockSubscribe;
35
+ unsubscribe = mockUnsubscribe;
36
+ getDocsForSubscription = mockGetDocsForSubscription;
37
+ subscribeStore = (listener: () => void) => {
38
+ storeListeners.add(listener);
39
+ return () => storeListeners.delete(listener);
40
+ };
41
+ mutate = vi.fn();
42
+ },
43
+ }));
44
+ vi.mock("./realtime", () => ({ RealtimeModule: class {} }));
45
+ vi.mock("./core/http", () => ({ HttpClient: class {} }));
46
+ vi.mock("./db", () => ({ DbModule: class {} }));
47
+ vi.mock("./util", () => ({ isBrowser: () => runtime.client }));
48
+ vi.mock("./fixtureDb", () => ({ FixtureDbModule: class {} }));
49
+
50
+ import { useFrappeCollection } from "./frappeReact";
51
+ import { useLiveQuery } from "./useLiveQuery";
52
+
53
+ type Doc = { name: string; doctype: string; title: string };
54
+
55
+ it.each([false, true])(
56
+ "hydrates a cached collection after an earlier reader (delayed=%s)",
57
+ async (delayed) => {
58
+ const config = { baseURL: `https://neutral-${delayed}.invalid`, realtime: false };
59
+ const collections = new Set<NonNullable<ReturnType<typeof useFrappeCollection<Doc>>>>();
60
+ let release: () => void = () => {};
61
+ let wait = false;
62
+ const pending = new Promise<void>((resolve) => {
63
+ release = resolve;
64
+ });
65
+ function Reader({ late = false, enabled = true }: { late?: boolean; enabled?: boolean }) {
66
+ if (late && wait) throw pending;
67
+ const collection = useFrappeCollection<Doc>(enabled ? config : undefined, "Workspace");
68
+ if (collection) collections.add(collection);
69
+ const result = useLiveQuery(
70
+ (q) => (collection ? q.from({ item: collection }) : null),
71
+ [collection],
72
+ );
73
+ return (
74
+ <div data-result={late ? "late" : "early"}>
75
+ {!enabled
76
+ ? "Disabled"
77
+ : !collection || result.isLoading
78
+ ? "Loading"
79
+ : result.data?.map((row) => row.title).join(",")}
80
+ </div>
81
+ );
82
+ }
83
+ const view = (
84
+ <Suspense fallback={<span>Boundary pending</span>}>
85
+ <Reader late />
86
+ </Suspense>
87
+ );
88
+ runtime.client = false;
89
+ const host = document.createElement("div");
90
+ host.innerHTML = renderToString(view);
91
+ expect(host.textContent).toBe("Loading");
92
+ const serverNode = host.querySelector("[data-result]");
93
+ document.body.append(host);
94
+ const earlyHost = document.createElement("div");
95
+ document.body.append(earlyHost);
96
+ runtime.client = true;
97
+ const errors = vi.fn();
98
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
99
+ let early: Root | undefined;
100
+ let late: Root | undefined;
101
+ try {
102
+ await act(async () => {
103
+ early = createRoot(earlyHost);
104
+ early.render(<Reader />);
105
+ });
106
+ await vi.waitFor(() => expect(earlyHost.textContent).toContain("Workspace One"));
107
+ const warm = [...collections][0];
108
+ expect(warm.status).toBe("ready");
109
+ wait = delayed;
110
+ await act(async () => {
111
+ late = hydrateRoot(host, view, { onRecoverableError: errors });
112
+ });
113
+ if (delayed) {
114
+ expect(host.querySelector("[data-result]")).toBe(serverNode);
115
+ expect(host.textContent).toBe("Loading");
116
+ await act(async () => {
117
+ wait = false;
118
+ release();
119
+ });
120
+ }
121
+ await vi.waitFor(() => expect(host.textContent).toContain("Workspace One"));
122
+ expect(errors.mock.calls.map(([e]) => String(e))).toEqual([]);
123
+ expect(
124
+ consoleError.mock.calls.filter((args) => /hydration|did not match/i.test(args.join(" "))),
125
+ ).toEqual([]);
126
+ expect(host.querySelector("[data-result]")).toBe(serverNode);
127
+ expect(collections.size).toBe(1);
128
+ await act(async () => {
129
+ for (const docs of docStore.values())
130
+ docs.push({ name: "ws-3", doctype: "Workspace", title: "Workspace Three" });
131
+ for (const listener of storeListeners) listener();
132
+ });
133
+ await vi.waitFor(() => expect(host.textContent).toContain("Workspace Three"));
134
+ await act(async () => late!.render(<Reader enabled={false} />));
135
+ expect(host.textContent).toBe("Disabled");
136
+ // A fresh boundary must use its own SSR snapshot after the shared cache
137
+ // and an earlier boundary have both committed updates.
138
+ await act(async () => late!.unmount());
139
+ runtime.client = false;
140
+ host.innerHTML = renderToString(view);
141
+ expect(host.textContent).toBe("Loading");
142
+ const freshServerNode = host.querySelector("[data-result]");
143
+ runtime.client = true;
144
+ await act(async () => {
145
+ late = hydrateRoot(host, view, { onRecoverableError: errors });
146
+ });
147
+ await vi.waitFor(() => expect(host.textContent).toContain("Workspace Three"));
148
+ expect(host.querySelector("[data-result]")).toBe(freshServerNode);
149
+ expect(errors).not.toHaveBeenCalled();
150
+ } finally {
151
+ await act(async () => {
152
+ early?.unmount();
153
+ late?.unmount();
154
+ });
155
+ host.remove();
156
+ earlyHost.remove();
157
+ consoleError.mockRestore();
158
+ }
159
+ await vi.waitFor(
160
+ () => expect([...collections].every((c) => c.subscriberCount === 0)).toBe(true),
161
+ { timeout: 10000 },
162
+ );
163
+ },
164
+ );
@@ -56,6 +56,7 @@ vi.mock("./fixtureDb", () => ({ FixtureDbModule: class {} }));
56
56
 
57
57
  import { isBrowser } from "./util";
58
58
  import { Order } from "./sync/types";
59
+ import type { FixtureProvider } from "./fixtures";
59
60
  import type { UseFrappeCollectionConfig, UseFrappeInfiniteListConfig } from "./frappeReact";
60
61
  import {
61
62
  __getActiveSyncModule,
@@ -341,6 +342,61 @@ describe("useFrappeCollection", () => {
341
342
  expect(active).toBeDefined();
342
343
  });
343
344
 
345
+ it("__getActiveSyncModule returns the SyncModule when the only one was created in fixture mode", async () => {
346
+ vi.resetModules();
347
+ const fresh = await import("./frappeReact");
348
+ expect(fresh.__getActiveSyncModule()).toBeUndefined();
349
+ const fixtures = {} as FixtureProvider;
350
+ renderHook(() => fresh.useFrappeCollection({ fixtures }, "Task"));
351
+ const active = fresh.__getActiveSyncModule();
352
+ expect(active).toBeDefined();
353
+ expect(active).toBe(mockCreateFrappeCollection.mock.calls.at(-1)?.[1].sync);
354
+ });
355
+
356
+ it("__getActiveSyncModule prefers a fixture-mode SyncModule created after live ones", () => {
357
+ renderHook(() =>
358
+ useFrappeCollection({ baseURL: `https://before-fixture-${testNum}.example.com` }, "Task"),
359
+ );
360
+ const fixtures = {} as FixtureProvider;
361
+ renderHook(() => useFrappeCollection({ fixtures }, "Task"));
362
+ const fixtureSync = mockCreateFrappeCollection.mock.calls.at(-1)?.[1].sync;
363
+ expect(fixtureSync).toBeDefined();
364
+ expect(__getActiveSyncModule()).toBe(fixtureSync);
365
+ });
366
+
367
+ it("__getActiveSyncModule falls back to the live cache once the latest SyncModule is collected", async () => {
368
+ vi.resetModules();
369
+ const fresh = await import("./frappeReact");
370
+ const live = fresh.getFrappeSyncModule({ baseURL: `https://collected-${testNum}.example.com` });
371
+ vi.stubGlobal(
372
+ "WeakRef",
373
+ class {
374
+ deref() {
375
+ return undefined;
376
+ }
377
+ },
378
+ );
379
+ try {
380
+ expect(fresh.getFrappeSyncModule({ fixtures: {} as FixtureProvider })).toBeDefined();
381
+ } finally {
382
+ vi.unstubAllGlobals();
383
+ }
384
+ expect(live).toBeDefined();
385
+ expect(fresh.__getActiveSyncModule()).toBe(live);
386
+ });
387
+
388
+ it("builds fixture-mode SyncModules on runtimes without WeakRef", async () => {
389
+ vi.resetModules();
390
+ const fresh = await import("./frappeReact");
391
+ vi.stubGlobal("WeakRef", undefined);
392
+ try {
393
+ expect(fresh.getFrappeSyncModule({ fixtures: {} as FixtureProvider })).toBeDefined();
394
+ } finally {
395
+ vi.unstubAllGlobals();
396
+ }
397
+ expect(fresh.__getActiveSyncModule()).toBeUndefined();
398
+ });
399
+
344
400
  it("passes socketOptions through to the RealtimeModule", () => {
345
401
  const socketOptions = { transports: ["polling", "websocket"] };
346
402
  const config: UseFrappeCollectionConfig = {
@@ -1,6 +1,6 @@
1
1
  import { isWindowDefined } from "@multiplatform.one/platform";
2
2
  import type { Collection } from "@tanstack/db";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import {
5
5
  createFrappeCollection,
6
6
  releaseFrappeCollectionSync,
@@ -13,7 +13,8 @@ import { FixtureDbModule } from "./fixtureDb";
13
13
  import { RealtimeModule } from "./realtime";
14
14
  import { SyncModule } from "./sync";
15
15
  import { Status } from "./sync/types";
16
- import type { DebugMutationEntry } from "./sync/types";
16
+ import type { DebugMutationEntry, SyncCatchUpState } from "./sync/types";
17
+ import { syncOptionsKey } from "./syncOptionsKey";
17
18
  import { isBrowser } from "./util";
18
19
 
19
20
  /**
@@ -72,13 +73,29 @@ export interface UseFrappeCollectionConfig {
72
73
  const syncModuleCache = new Map<string, SyncModule>();
73
74
  // Fixture-mode SyncModules keyed by the FixtureProvider instance so each unique
74
75
  // fixture set gets its own SyncModule (prevents cross-test cache pollution).
75
- const fixtureSyncModuleCache = new WeakMap<import("./fixtures").FixtureProvider, SyncModule>();
76
+ const fixtureSyncModuleCache = new WeakMap<
77
+ import("./fixtures").FixtureProvider,
78
+ Map<string, SyncModule>
79
+ >();
80
+ // A WeakMap can't be enumerated, so this slot is how the accessor below sees
81
+ // a fixture-mode module. WeakRef rather than an explicit clear: nothing
82
+ // refcounts SyncModules, and a strong slot would pin a discarded fixture
83
+ // set's module (and its FixtureProvider) and hand devtools a dead one.
84
+ // Runtimes without WeakRef never fill the slot and keep the live-cache scan.
85
+ let latestSyncModule: WeakRef<SyncModule> | undefined;
86
+
87
+ function rememberLatestSyncModule(sync: SyncModule): void {
88
+ if (typeof WeakRef === "function") latestSyncModule = new WeakRef(sync);
89
+ }
76
90
 
77
91
  /**
78
- * Returns the first active SyncModule, or undefined if none exist.
92
+ * Returns the most recently constructed SyncModule (live or fixture mode),
93
+ * falling back to the first live one, or undefined if none exist.
79
94
  * Intended for devtools integration — not part of the public API.
80
95
  */
81
96
  export function __getActiveSyncModule(): SyncModule | undefined {
97
+ const latest = latestSyncModule?.deref();
98
+ if (latest) return latest;
82
99
  for (const sync of syncModuleCache.values()) return sync;
83
100
  return undefined;
84
101
  }
@@ -115,13 +132,20 @@ export function getFrappeSyncModule(
115
132
  */
116
133
  function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefined {
117
134
  if (!isBrowser()) return undefined;
135
+ const optionsKey = syncOptionsKey(config.syncOptions);
118
136
 
119
137
  if (config.fixtures) {
120
- let sync = fixtureSyncModuleCache.get(config.fixtures);
138
+ let cache = fixtureSyncModuleCache.get(config.fixtures);
139
+ if (!cache) {
140
+ cache = new Map();
141
+ fixtureSyncModuleCache.set(config.fixtures, cache);
142
+ }
143
+ let sync = cache.get(optionsKey);
121
144
  if (!sync) {
122
145
  const db = new FixtureDbModule(config.fixtures) as unknown as DbModule;
123
146
  sync = new SyncModule(db, undefined, config.syncOptions);
124
- fixtureSyncModuleCache.set(config.fixtures, sync);
147
+ cache.set(optionsKey, sync);
148
+ rememberLatestSyncModule(sync);
125
149
  }
126
150
  return sync;
127
151
  }
@@ -143,17 +167,10 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
143
167
  config.socketPort ?? "",
144
168
  config.socketBaseURL ?? "",
145
169
  config.siteName ?? "",
170
+ optionsKey,
146
171
  ].join("|");
147
172
  let sync = syncModuleCache.get(key);
148
173
  if (!sync) {
149
- // The FIRST config for a baseURL wins for every later consumer (the
150
- // module is shared) — surface what it was constructed with so a
151
- // missing persistKey/cdcInterval is traceable to the winning mount.
152
- console.debug(
153
- `[frappe-sync] SyncModule created for ${key} ` +
154
- `(persistKey=${config.syncOptions?.persistKey ?? "none"}, ` +
155
- `cdcInterval=${config.syncOptions?.cdcInterval ?? "off"})`,
156
- );
157
174
  const authConfig: AuthConfig | undefined = config.auth
158
175
  ? {
159
176
  useToken: config.auth.useToken,
@@ -182,6 +199,7 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
182
199
  config.syncOptions,
183
200
  );
184
201
  syncModuleCache.set(key, sync);
202
+ rememberLatestSyncModule(sync);
185
203
  }
186
204
  return sync;
187
205
  }
@@ -462,6 +480,10 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
462
480
  entry.gcTimer = setTimeout(tick, SHARED_COLLECTION_GC_MS);
463
481
  }
464
482
 
483
+ const subscribeHydration = () => () => {};
484
+ const clientCollectionAvailable = () => true;
485
+ const serverCollectionAvailable = () => false;
486
+
465
487
  /**
466
488
  * React hook that returns a TanStack DB Collection backed by a Frappe doctype.
467
489
  *
@@ -485,7 +507,7 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
485
507
  * @param config - Connection configuration (baseURL, auth)
486
508
  * @param doctype - The Frappe doctype name
487
509
  * @param options - Optional collection configuration (fields, filters, etc.)
488
- * @returns A stable TanStack DB Collection instance, or undefined if config is not provided
510
+ * @returns A stable collection, or undefined during SSR/hydration or without config
489
511
  *
490
512
  * @example
491
513
  * ```tsx
@@ -512,6 +534,13 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
512
534
  doctype: string,
513
535
  options?: Omit<FrappeCollectionOptionsConfig<TDoc>, "sync">,
514
536
  ): Collection<TDoc, string> | undefined {
537
+ // Each hydrating boundary must match SSR even if an earlier reader has
538
+ // already filled the shared cache. Client-only and native mounts read true.
539
+ const collectionAvailable = useSyncExternalStore(
540
+ subscribeHydration,
541
+ clientCollectionAvailable,
542
+ serverCollectionAvailable,
543
+ );
515
544
  // Consumers pass inline onSyncError closures; keep one stable wrapper per
516
545
  // hook instance so the entry's handler registry doesn't churn every render.
517
546
  const errorHandlerRef = useRef(options?.onSyncError);
@@ -523,16 +552,16 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
523
552
  const [, setStaleEntryBump] = useState(0);
524
553
 
525
554
  // Resolve the shared entry during render so the collection is available on
526
- // the FIRST render (same contract as before the cache). An empty doctype is
555
+ // the first client-only/native render and after hydration. An empty doctype is
527
556
  // treated like an undefined config: consumers such as DynamicLink pass ""
528
557
  // before a doctype is chosen, and syncing doctype "" fires a bogus
529
558
  // `/api/v2/document/` 404. During SSR, getSyncModule returns undefined —
530
559
  // the component renders a loading state and the client hydration pass
531
- // creates the real collection.
560
+ // exposes the real collection after this boundary hydrates.
532
561
  let cache: Map<string, SharedCollectionEntry> | undefined;
533
562
  let key = "";
534
563
  let entry: SharedCollectionEntry | undefined;
535
- if (config && doctype) {
564
+ if (collectionAvailable && config && doctype) {
536
565
  const syncModule = getSyncModule(config);
537
566
  if (syncModule) {
538
567
  key = collectionOptionsKey(doctype, options);
@@ -580,6 +609,31 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
580
609
  // Connection / realtime hooks
581
610
  // ---------------------------------------------------------------------------
582
611
 
612
+ export interface UseFrappeCatchUpResult extends SyncCatchUpState {
613
+ retry: () => Promise<SyncCatchUpState>;
614
+ }
615
+
616
+ const idleCatchUpState: SyncCatchUpState = Object.freeze({
617
+ refreshing: false,
618
+ stale: false,
619
+ error: null,
620
+ });
621
+
622
+ /** Connection-level refresh qualification. Retry resolves with state, including failures. */
623
+ export function useFrappeCatchUp(
624
+ config: UseFrappeCollectionConfig | undefined,
625
+ ): UseFrappeCatchUpResult {
626
+ const sync = getFrappeSyncModule(config);
627
+ const subscribe = useCallback(
628
+ (listener: () => void) => sync?.subscribeStore(listener) ?? (() => {}),
629
+ [sync],
630
+ );
631
+ const snapshot = useCallback(() => sync?.getCatchUpState() ?? idleCatchUpState, [sync]);
632
+ const state = useSyncExternalStore(subscribe, snapshot, () => idleCatchUpState);
633
+ const retry = useCallback(() => sync?.catchUp() ?? Promise.resolve(idleCatchUpState), [sync]);
634
+ return useMemo(() => ({ ...state, retry }), [state, retry]);
635
+ }
636
+
583
637
  /** Snapshot returned by {@link useFrappeConnection}. */
584
638
  export interface FrappeConnectionState {
585
639
  /** Socket connected right now. */
@@ -605,14 +659,14 @@ export function useFrappeConnection(
605
659
  config: UseFrappeCollectionConfig | undefined,
606
660
  ): FrappeConnectionState {
607
661
  const [state, setState] = useState<FrappeConnectionState>(DISCONNECTED_STATE);
662
+ const syncModule =
663
+ config && !config.fixtures && config.realtime !== false ? getSyncModule(config) : undefined;
608
664
 
609
665
  useEffect(() => {
610
- if (!config || config.fixtures || config.realtime === false || !isBrowser()) {
666
+ if (!syncModule) {
611
667
  setState(DISCONNECTED_STATE);
612
668
  return;
613
669
  }
614
- const syncModule = getSyncModule(config);
615
- if (!syncModule) return;
616
670
 
617
671
  const read = () => {
618
672
  const next = syncModule.getConnectionState();
@@ -625,7 +679,7 @@ export function useFrappeConnection(
625
679
  read();
626
680
  return syncModule.subscribeStore(read);
627
681
  // eslint-disable-next-line react-hooks/exhaustive-deps
628
- }, [config?.baseURL, config?.realtime, !!config?.fixtures]);
682
+ }, [syncModule]);
629
683
 
630
684
  return config?.realtime === false ? DISCONNECTED_STATE : state;
631
685
  }
@@ -646,18 +700,12 @@ export function useFrappeDoctypeEvents(
646
700
  ): void {
647
701
  const handlerRef = useRef(onEvent);
648
702
  handlerRef.current = onEvent;
703
+ const syncModule =
704
+ enabled && config && !config.fixtures && config.realtime !== false && doctype
705
+ ? getSyncModule(config)
706
+ : undefined;
649
707
 
650
708
  useEffect(() => {
651
- if (
652
- !enabled ||
653
- !config ||
654
- config.fixtures ||
655
- config.realtime === false ||
656
- !doctype ||
657
- !isBrowser()
658
- )
659
- return;
660
- const syncModule = getSyncModule(config);
661
709
  if (!syncModule) return;
662
710
 
663
711
  syncModule.watchDoctype(doctype);
@@ -672,7 +720,7 @@ export function useFrappeDoctypeEvents(
672
720
  syncModule.unwatchDoctype(doctype);
673
721
  };
674
722
  // eslint-disable-next-line react-hooks/exhaustive-deps
675
- }, [enabled, config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
723
+ }, [syncModule, doctype]);
676
724
  }
677
725
 
678
726
  // ---------------------------------------------------------------------------
@@ -712,17 +760,16 @@ export function useFrappePendingMutations(
712
760
  ): UseFrappePendingMutationsResult {
713
761
  const [entries, setEntries] = useState<DebugMutationEntry[]>(EMPTY_LEDGER);
714
762
  const syncModuleRef = useRef<SyncModule | undefined>(undefined);
763
+ const syncModule = getFrappeSyncModule(config);
715
764
 
716
765
  useEffect(() => {
717
- if (!config || !isBrowser()) {
766
+ if (!syncModule) {
718
767
  setEntries(EMPTY_LEDGER);
719
768
  return;
720
769
  }
721
- const syncModule = getSyncModule(config);
722
- if (!syncModule) return;
723
770
  syncModuleRef.current = syncModule;
724
771
 
725
- let lastSignature = "";
772
+ let lastSignature: string | undefined;
726
773
  const read = () => {
727
774
  const next = syncModule.getMutationEntries(doctype);
728
775
  const signature = next.map((e) => `${e.id}:${e.status}:${e.retryCount}`).join("|");
@@ -731,9 +778,13 @@ export function useFrappePendingMutations(
731
778
  setEntries(next.length === 0 ? EMPTY_LEDGER : next);
732
779
  };
733
780
  read();
734
- return syncModule.subscribeStore(read);
781
+ const unsubscribe = syncModule.subscribeStore(read);
782
+ return () => {
783
+ unsubscribe();
784
+ syncModuleRef.current = undefined;
785
+ };
735
786
  // eslint-disable-next-line react-hooks/exhaustive-deps
736
- }, [config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
787
+ }, [syncModule, doctype]);
737
788
 
738
789
  const retry = useCallback(async () => {
739
790
  await syncModuleRef.current?.replayPending();
@@ -1039,12 +1090,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
1039
1090
 
1040
1091
  // Stable serialised key for filters so we can depend on it in useEffect
1041
1092
  const filtersKey = JSON.stringify(filters);
1093
+ const syncModule = getFrappeSyncModule(config);
1042
1094
 
1043
1095
  // Subscribe via SyncModule with progressive: true
1044
1096
  useEffect(() => {
1045
- if (!config || !isBrowser()) return;
1046
-
1047
- const syncModule = getSyncModule(config);
1048
1097
  if (!syncModule) return;
1049
1098
  syncModuleRef.current = syncModule;
1050
1099
 
@@ -1100,9 +1149,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
1100
1149
  syncModule.unsubscribe(subscriptionIdRef.current);
1101
1150
  subscriptionIdRef.current = undefined;
1102
1151
  }
1152
+ syncModuleRef.current = undefined;
1103
1153
  };
1104
1154
  // eslint-disable-next-line react-hooks/exhaustive-deps
1105
- }, [config?.baseURL, config?.realtime, config?.auth?.token, doctype, filtersKey, pageSize]);
1155
+ }, [syncModule, config?.auth?.token, doctype, filtersKey, pageSize]);
1106
1156
 
1107
1157
  const loadMore = useCallback(async () => {
1108
1158
  const syncModule = syncModuleRef.current;
package/src/index.ts CHANGED
@@ -8,6 +8,13 @@
8
8
  // Core Frappe functionality
9
9
  export { FrappeApp } from "./frappeApp";
10
10
  export type { FrappeConfig } from "./frappeApp";
11
+ export {
12
+ loadDoc,
13
+ loadDocList,
14
+ type FrappeLoaderRequest,
15
+ type LoadDocListOptions,
16
+ type LoadDocOptions,
17
+ } from "./serverLoaders";
11
18
  export { AuthModule } from "./auth";
12
19
  export { CallModule } from "./call";
13
20
  export { DbModule } from "./db";
@@ -47,6 +54,7 @@ export type { OptimisticRpcContext, OptimisticRpcOptions } from "./optimistic";
47
54
  // React hooks for Frappe + TanStack DB
48
55
  export {
49
56
  useFrappeCollection,
57
+ useFrappeCatchUp,
50
58
  useFrappeConnection,
51
59
  useFrappeDoctypeEvents,
52
60
  useFrappePendingMutations,
@@ -57,6 +65,7 @@ export {
57
65
  } from "./frappeReact";
58
66
  export type {
59
67
  FrappeConnectionState,
68
+ UseFrappeCatchUpResult,
60
69
  UseFrappeCollectionConfig,
61
70
  UseFrappePendingMutationsResult,
62
71
  UseInfiniteScrollConfig,
@@ -172,11 +181,15 @@ export {
172
181
  useDocTypeSearch,
173
182
  parseDependsOn,
174
183
  parseFormLayout,
184
+ stockFieldTypes,
185
+ customFieldTypes,
186
+ frappeFieldTypes,
175
187
  } from "./doctype";
176
188
  export type {
177
189
  DocTypeMeta,
178
190
  DocTypeField,
179
191
  DocTypePermission,
192
+ DocTypeLink,
180
193
  FormSection,
181
194
  FormColumn,
182
195
  FrappeFieldType,
@@ -191,6 +204,7 @@ export type { FrappeDocValue, FrappeDocData, FrappeDoc, ChildTableRow } from "./
191
204
  // Sync types with advanced features
192
205
  export type {
193
206
  SyncOptions,
207
+ SyncCatchUpState,
194
208
  PageBoundaries,
195
209
  RehydrationStrategy,
196
210
  Mutation,
@@ -0,0 +1,77 @@
1
+ import type { ListOptions } from "./core/types";
2
+ import { FrappeApp } from "./frappeApp";
3
+
4
+ /** The slice of a route loader's `Request` the server reads: its cookie header. */
5
+ export interface FrappeLoaderRequest {
6
+ headers?: { get(name: string): string | null } | null;
7
+ }
8
+
9
+ interface LoaderTarget {
10
+ /** Bench address the server dials (not the browser's). */
11
+ baseURL: string;
12
+ doctype: string;
13
+ /**
14
+ * The incoming request. Its cookie rides along so the server reads with
15
+ * the visitor's session. Absent (a loader run in the browser), nothing is
16
+ * fetched: the live collection reads instead.
17
+ */
18
+ request?: FrappeLoaderRequest | null;
19
+ }
20
+
21
+ export type LoadDocListOptions = LoaderTarget &
22
+ Pick<ListOptions, "fields" | "filters" | "orderBy" | "limit">;
23
+
24
+ export interface LoadDocOptions extends LoaderTarget {
25
+ name: string;
26
+ }
27
+
28
+ function serverApp(baseURL: string, request: FrappeLoaderRequest): FrappeApp {
29
+ const cookie = request.headers?.get("cookie") ?? undefined;
30
+ return new FrappeApp(baseURL, cookie ? { cookie } : {});
31
+ }
32
+
33
+ function isNotFound(error: unknown): boolean {
34
+ return (
35
+ typeof error === "object" && error !== null && (error as { status?: number }).status === 404
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Rows for a server-rendered list, read with the query the page's live
41
+ * collection will use: hand both the same fields, filters, order and limit,
42
+ * or the collection's first sync replaces the rendered page with a
43
+ * different one (a 20-row default page shrank a 21-row list, MPO-322).
44
+ * A failed read renders an empty page rather than failing the route.
45
+ */
46
+ export async function loadDocList<TDoc extends { name: string; doctype: string }>({
47
+ baseURL,
48
+ doctype,
49
+ request,
50
+ ...query
51
+ }: LoadDocListOptions): Promise<TDoc[]> {
52
+ if (!request) return [];
53
+ try {
54
+ const rows = await serverApp(baseURL, request).db().getDocList<TDoc>(doctype, query);
55
+ return rows.map((row) => ({ ...row, doctype }) as TDoc);
56
+ } catch (error) {
57
+ console.error(`[loadDocList] ${doctype}:`, error);
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** One document, child tables included, for a server-rendered detail page; null when missing. */
63
+ export async function loadDoc<TDoc extends { name: string; doctype: string }>({
64
+ baseURL,
65
+ doctype,
66
+ name,
67
+ request,
68
+ }: LoadDocOptions): Promise<TDoc | null> {
69
+ if (!request || !name) return null;
70
+ try {
71
+ const doc = await serverApp(baseURL, request).db().getDoc<TDoc>(doctype, name);
72
+ return doc ? ({ ...doc, doctype } as TDoc) : null;
73
+ } catch (error) {
74
+ if (!isNotFound(error)) console.error(`[loadDoc] ${doctype}/${name}:`, error);
75
+ return null;
76
+ }
77
+ }