@multiplatform.one/frappe 7.10.0 → 7.11.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 (71) hide show
  1. package/README.md +58 -0
  2. package/dist/cjs/frappeReact.cjs +54 -30
  3. package/dist/cjs/frappeReact.native.js +67 -32
  4. package/dist/cjs/frappeReact.native.js.map +1 -1
  5. package/dist/cjs/index.cjs +1 -0
  6. package/dist/cjs/index.native.js +1 -0
  7. package/dist/cjs/index.native.js.map +1 -1
  8. package/dist/cjs/sync/index.cjs +91 -14
  9. package/dist/cjs/sync/index.native.js +119 -14
  10. package/dist/cjs/sync/index.native.js.map +1 -1
  11. package/dist/cjs/sync/types.native.js.map +1 -1
  12. package/dist/cjs/syncOptionsKey.cjs +59 -0
  13. package/dist/cjs/syncOptionsKey.native.js +82 -0
  14. package/dist/cjs/syncOptionsKey.native.js.map +1 -0
  15. package/dist/esm/frappeReact.mjs +55 -32
  16. package/dist/esm/frappeReact.mjs.map +1 -1
  17. package/dist/esm/frappeReact.native.js +68 -34
  18. package/dist/esm/frappeReact.native.js.map +1 -1
  19. package/dist/esm/index.mjs +2 -2
  20. package/dist/esm/index.mjs.map +1 -1
  21. package/dist/esm/index.native.js +2 -2
  22. package/dist/esm/index.native.js.map +1 -1
  23. package/dist/esm/sync/index.mjs +91 -14
  24. package/dist/esm/sync/index.mjs.map +1 -1
  25. package/dist/esm/sync/index.native.js +119 -14
  26. package/dist/esm/sync/index.native.js.map +1 -1
  27. package/dist/esm/sync/types.mjs.map +1 -1
  28. package/dist/esm/sync/types.native.js.map +1 -1
  29. package/dist/esm/syncOptionsKey.mjs +34 -0
  30. package/dist/esm/syncOptionsKey.mjs.map +1 -0
  31. package/dist/esm/syncOptionsKey.native.js +54 -0
  32. package/dist/esm/syncOptionsKey.native.js.map +1 -0
  33. package/dist/jsx/frappeReact.mjs +55 -32
  34. package/dist/jsx/frappeReact.mjs.map +1 -1
  35. package/dist/jsx/frappeReact.native.js +67 -32
  36. package/dist/jsx/frappeReact.native.js.map +1 -1
  37. package/dist/jsx/index.js +2 -2
  38. package/dist/jsx/index.js.map +1 -1
  39. package/dist/jsx/index.mjs +2 -2
  40. package/dist/jsx/index.mjs.map +1 -1
  41. package/dist/jsx/index.native.js +1 -0
  42. package/dist/jsx/index.native.js.map +1 -1
  43. package/dist/jsx/sync/index.mjs +91 -14
  44. package/dist/jsx/sync/index.mjs.map +1 -1
  45. package/dist/jsx/sync/index.native.js +119 -14
  46. package/dist/jsx/sync/index.native.js.map +1 -1
  47. package/dist/jsx/sync/types.mjs.map +1 -1
  48. package/dist/jsx/sync/types.native.js.map +1 -1
  49. package/dist/jsx/syncOptionsKey.mjs +34 -0
  50. package/dist/jsx/syncOptionsKey.mjs.map +1 -0
  51. package/dist/jsx/syncOptionsKey.native.js +82 -0
  52. package/dist/jsx/syncOptionsKey.native.js.map +1 -0
  53. package/package.json +9 -9
  54. package/src/frappeReact.catchUp.spec.tsx +105 -0
  55. package/src/frappeReact.ts +62 -38
  56. package/src/index.ts +3 -0
  57. package/src/sync/catchUp.spec.ts +142 -0
  58. package/src/sync/index.ts +97 -9
  59. package/src/sync/types.ts +9 -1
  60. package/src/syncOptionsKey.spec.ts +46 -0
  61. package/src/syncOptionsKey.ts +45 -0
  62. package/types/frappeReact.d.ts +6 -1
  63. package/types/frappeReact.d.ts.map +1 -1
  64. package/types/index.d.ts +3 -3
  65. package/types/index.d.ts.map +1 -1
  66. package/types/sync/index.d.ts +9 -1
  67. package/types/sync/index.d.ts.map +1 -1
  68. package/types/sync/types.d.ts +8 -1
  69. package/types/sync/types.d.ts.map +1 -1
  70. package/types/syncOptionsKey.d.ts +3 -0
  71. package/types/syncOptionsKey.d.ts.map +1 -0
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all) __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: () => from[key],
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
23
+ value: true
24
+ }), mod);
25
+ var syncOptionsKey_exports = {};
26
+ __export(syncOptionsKey_exports, {
27
+ syncOptionsKey: () => syncOptionsKey
28
+ });
29
+ module.exports = __toCommonJS(syncOptionsKey_exports);
30
+ function _type_of(obj) {
31
+ "@swc/helpers - typeof";
32
+
33
+ return obj && typeof Symbol !== "undefined" && obj.constructor === Symbol ? "symbol" : typeof obj;
34
+ }
35
+ var identities = /* @__PURE__ */new WeakMap();
36
+ var nextIdentity = 0;
37
+ function identity(value) {
38
+ var id = identities.get(value);
39
+ if (id === void 0) {
40
+ id = ++nextIdentity;
41
+ identities.set(value, id);
42
+ }
43
+ return ["reference", id];
44
+ }
45
+ function stableValue(value) {
46
+ var ancestors = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : /* @__PURE__ */new Set();
47
+ if (typeof value === "function") return identity(value);
48
+ if (value === null || (typeof value === "undefined" ? "undefined" : _type_of(value)) !== "object") return [typeof value === "undefined" ? "undefined" : _type_of(value), String(value)];
49
+ var prototype = Object.getPrototypeOf(value);
50
+ if (ancestors.has(value) || !Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
51
+ return identity(value);
52
+ }
53
+ var nested = new Set(ancestors).add(value);
54
+ if (Array.isArray(value)) return ["array", value.map(function (item) {
55
+ return stableValue(item, nested);
56
+ })];
57
+ return ["record", Object.entries(value).filter(function (param) {
58
+ var [, item] = param;
59
+ return item !== void 0;
60
+ }).sort(function (param, param1) {
61
+ var [left] = param,
62
+ [right] = param1;
63
+ return left.localeCompare(right);
64
+ }).map(function (param) {
65
+ var [key, item] = param;
66
+ return [key, stableValue(item, nested)];
67
+ })];
68
+ }
69
+ function syncOptionsKey() {
70
+ var options = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : {};
71
+ var _options_catchUpStrategy;
72
+ var {
73
+ storage,
74
+ ...values
75
+ } = options;
76
+ return JSON.stringify([stableValue({
77
+ ...values,
78
+ catchUpStrategy: (_options_catchUpStrategy = options.catchUpStrategy) !== null && _options_catchUpStrategy !== void 0 ? _options_catchUpStrategy : "cdc",
79
+ cdcInterval: options.cdcInterval && options.cdcInterval > 0 ? options.cdcInterval : 0
80
+ }), storage ? identity(storage) : null]);
81
+ }
82
+ //# sourceMappingURL=syncOptionsKey.native.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["__defProp","Object","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable","__copyProps","to","from","except","desc","key","call","__toCommonJS","mod","value","syncOptionsKey_exports","syncOptionsKey","module","exports","_type_of","obj","Symbol","constructor","identities","WeakMap","nextIdentity","identity","id","set"],"sources":["../../src/syncOptionsKey.ts"],"sourcesContent":[null],"mappings":"AAEA,YAAM;;AACN,IAAIA,SAAA,GAAAC,MAAe,CAAAC,cAAA;AACnB,IAAAC,gBAAkB,GAAAF,MAAe,CAAAG,wBAAA;AAC/B,IAAAC,iBAAS,GAAWJ,MAAI,CAAKK,mBAAA;AAC7B,IAAAC,YAAW,GAAAN,MAAW,CAAAO,SAAA,CAAAC,cAAA;AACpB,IAAAC,QAAO,GAAAA,CAAAC,MAAA,EAAAC,GAAA;EACP,SAAAC,IAAW,IAAID,GAAA,EACjBZ,SAAA,CAAAW,MAAA,EAAAE,IAAA;IAAAC,GAAA,EAAAF,GAAA,CAAAC,IAAA;IAAAE,UAAA;EAAA;AACA;AACF,IAAAC,WAAA,GAAAA,CAAAC,EAAA,EAAAC,IAAA,EAAAC,MAAA,EAAAC,IAAA;EAEA,IAAAF,IAAS,WAAYA,IAAA,KAAgB,YAAY,OAAAA,IAAA,eAA4B;IAC3E,KAAI,IAAOG,GAAA,IAAAhB,iBAAsB,CAAAa,IAAO,GACpC,KAAAX,YAAU,CAAQe,IAAA,CAAAL,EAAO,EAAAI,GAAA,KAAUA,GAAA,KAAAF,MAAU,EAC3CnB,SAAA,CAAAiB,EAAY,EAAAI,GAAA,EAAO;MAAAP,GAAA,EAAAA,CAAA,KAAAI,IAAe,CAAAG,GAAK;MAAAN,UAAA,IAAAK,IAAA,GAAAjB,gBAAA,CAAAe,IAAA,EAAAG,GAAA,MAAAD,IAAA,CAAAL;IAAA;EAC7C;EAIE,OAAAE,EAAO;AAAc;AAEvB,IAAAM,YAAM,GAASC,GAAI,IAAIR,WAAW,CAAIhB,SAAK;EAAAyB,KAAA;AAAA,IAAAD,GAAA;AAC3C,IAAAE,sBAAuB,GAAG;AAC1BhB,QAAA,CAAOgB,sBAAA;EAAAC,cACL,EAAAA,CAAA,KAAAA;AAAA;AAIwDC,MAC1D,CAAAC,OAAA,GAAAN,YAAA,CAAAG,sBAAA;AACF,SAAAI,SAAAC,GAAA;EAEO,uBAAwB;;EAC7B,OAAMA,GAAE,WAAYC,MAAO,KAAI,eAAAD,GAAA,CAAAE,WAAA,KAAAD,MAAA,qBAAAD,GAAA;AAC/B;AAAsB,IACpBG,UAAA,GAAY,mBAAAC,OAAA;AAAA,IAAAC,YACP;AAAA,SACHC,SAAAZ,KAAiB;EAA2B,IAC5Ca,EAAA,GAAAJ,UAAa,CAAApB,GAAA,CAAAW,KAAQ;EAA+D,IACrFa,EAAA;IACDA,EAAA,KAAAF,YAAmB;IACpBF,UAAA,CAAAK,GAAA,CAAAd,KAAA,EAAAa,EAAA;EACH","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/frappe",
3
- "version": "7.10.0",
3
+ "version": "7.11.0",
4
4
  "description": "frappe client for multiplatform.one ecosystem",
5
5
  "keywords": [
6
6
  "auth",
@@ -60,15 +60,18 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
+ "@multiplatform.one/store": "7.11.0",
63
64
  "@tanstack/db": "0.6.5",
64
65
  "@tanstack/pacer": "0.21.0",
65
66
  "@tanstack/react-db": "0.1.83",
66
67
  "@tanstack/react-pacer": "0.22.0",
67
68
  "broadcast-channel": "^7.3.0",
68
- "socket.io-client": "^4.8.3",
69
- "@multiplatform.one/store": "7.10.0"
69
+ "socket.io-client": "^4.8.3"
70
70
  },
71
71
  "devDependencies": {
72
+ "@multiplatform.one/config": "7.11.0",
73
+ "@multiplatform.one/platform": "7.11.0",
74
+ "@multiplatform.one/test-utils": "7.11.0",
72
75
  "@tamagui/build": "2.7.6",
73
76
  "@testing-library/jest-dom": "^6.9.1",
74
77
  "@testing-library/react": "^16.3.2",
@@ -78,14 +81,11 @@
78
81
  "react": "19.2.5",
79
82
  "tamagui": "2.7.6",
80
83
  "typescript": "~5.9.3",
81
- "vitest": "^4.1.5",
82
- "@multiplatform.one/config": "7.10.0",
83
- "@multiplatform.one/platform": "7.10.0",
84
- "@multiplatform.one/test-utils": "7.10.0"
84
+ "vitest": "^4.1.5"
85
85
  },
86
86
  "peerDependencies": {
87
- "react": "^19.1.0",
88
- "@multiplatform.one/platform": "^7.10.0"
87
+ "@multiplatform.one/platform": "^7.11.0",
88
+ "react": "^19.1.0"
89
89
  },
90
90
  "engines": {
91
91
  "node": ">=6.0.0"
@@ -0,0 +1,105 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { getFrappeSyncModule, useFrappeCatchUp, useFrappePendingMutations } from "./frappeReact";
4
+ import { Operation, Status } from "./sync/types";
5
+ import { InMemoryFixtureProvider } from "./fixtures";
6
+ import type { SyncModule } from "./sync";
7
+ import type { UseFrappeCollectionConfig } from "./frappeReact";
8
+
9
+ const modules = new Set<SyncModule>();
10
+ function moduleFor(config: UseFrappeCollectionConfig) {
11
+ const sync = getFrappeSyncModule(config)!;
12
+ expect(sync).toBeDefined();
13
+ modules.add(sync);
14
+ return sync;
15
+ }
16
+ afterEach(async () => {
17
+ for (const sync of modules) await sync.dispose();
18
+ modules.clear();
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ it.each([false, true])(
23
+ "separates effective options in the shared cache, fixtures=%s",
24
+ (fixture) => {
25
+ const config = fixture
26
+ ? { fixtures: new InMemoryFixtureProvider({}) }
27
+ : { baseURL: "https://cache-options.test", realtime: false };
28
+ const defaultSync = moduleFor(config);
29
+ expect(moduleFor({ ...config, syncOptions: { catchUpStrategy: "cdc", cdcInterval: 0 } })).toBe(
30
+ defaultSync,
31
+ );
32
+ const snapshot = moduleFor({ ...config, syncOptions: { catchUpStrategy: "snapshot" } });
33
+ expect(snapshot).not.toBe(defaultSync);
34
+ expect(moduleFor({ ...config, syncOptions: { catchUpStrategy: "snapshot" } })).toBe(snapshot);
35
+ expect(moduleFor({ ...config, syncOptions: { cdcInterval: 1000 } })).not.toBe(defaultSync);
36
+ },
37
+ );
38
+
39
+ it("follows the selected module, retains failure state during retry and does not retry on render", async () => {
40
+ const transport = vi.fn(async () => Response.json({ data: [{ name: "a", doctype: "Task" }] }));
41
+ vi.stubGlobal("fetch", transport);
42
+ const cdc = { baseURL: "https://hook-options.test", realtime: false };
43
+ const snapshot = { ...cdc, syncOptions: { catchUpStrategy: "snapshot" as const } };
44
+ const first = moduleFor(cdc);
45
+ const second = moduleFor(snapshot);
46
+ await first.subscribe({ doctype: "Task" });
47
+ await second.subscribe({ doctype: "Task" });
48
+ const view = renderHook(({ config }) => useFrappeCatchUp(config), {
49
+ initialProps: { config: cdc as UseFrappeCollectionConfig | undefined },
50
+ });
51
+ transport.mockImplementation(async () =>
52
+ Response.json({ message: "unavailable" }, { status: 404 }),
53
+ );
54
+ await act(async () => {
55
+ await view.result.current.retry();
56
+ });
57
+ expect(view.result.current).toMatchObject({
58
+ refreshing: false,
59
+ stale: true,
60
+ error: { status: 404 },
61
+ });
62
+ const calls = transport.mock.calls.length;
63
+ view.rerender({ config: { ...cdc } });
64
+ expect(transport).toHaveBeenCalledTimes(calls);
65
+ view.rerender({ config: snapshot });
66
+ await waitFor(() => expect(view.result.current.stale).toBe(false));
67
+ transport.mockImplementation(async () => Response.json({ data: [] }));
68
+ await act(async () => {
69
+ await view.result.current.retry();
70
+ });
71
+ expect(view.result.current.error).toBeNull();
72
+ expect(second.__debug().subscriptions[0].docCount).toBe(0);
73
+ expect(first.__debug().subscriptions[0].docCount).toBe(1);
74
+ view.rerender({ config: undefined });
75
+ expect(await view.result.current.retry()).toEqual({
76
+ refreshing: false,
77
+ stale: false,
78
+ error: null,
79
+ });
80
+ view.unmount();
81
+ });
82
+
83
+ it("drops the previous module's mutation notice when changed options select an empty ledger", () => {
84
+ const config = { fixtures: new InMemoryFixtureProvider({}) };
85
+ const next = { ...config, syncOptions: { catchUpStrategy: "snapshot" as const } };
86
+ vi.spyOn(moduleFor(config), "getMutationEntries").mockReturnValue([
87
+ {
88
+ id: "old",
89
+ operation: Operation.Update,
90
+ doctype: "Task",
91
+ status: Status.Failed,
92
+ createdAt: 1,
93
+ retryCount: 0,
94
+ error: "old module failure",
95
+ },
96
+ ]);
97
+ moduleFor(next);
98
+ const view = renderHook(({ connection }) => useFrappePendingMutations(connection), {
99
+ initialProps: { connection: config as UseFrappeCollectionConfig },
100
+ });
101
+ expect(view.result.current.failed.map((entry) => entry.id)).toEqual(["old"]);
102
+ view.rerender({ connection: next });
103
+ expect(view.result.current.entries).toEqual([]);
104
+ view.unmount();
105
+ });
@@ -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,7 +73,10 @@ 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
+ >();
76
80
 
77
81
  /**
78
82
  * Returns the first active SyncModule, or undefined if none exist.
@@ -115,13 +119,19 @@ export function getFrappeSyncModule(
115
119
  */
116
120
  function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefined {
117
121
  if (!isBrowser()) return undefined;
122
+ const optionsKey = syncOptionsKey(config.syncOptions);
118
123
 
119
124
  if (config.fixtures) {
120
- let sync = fixtureSyncModuleCache.get(config.fixtures);
125
+ let cache = fixtureSyncModuleCache.get(config.fixtures);
126
+ if (!cache) {
127
+ cache = new Map();
128
+ fixtureSyncModuleCache.set(config.fixtures, cache);
129
+ }
130
+ let sync = cache.get(optionsKey);
121
131
  if (!sync) {
122
132
  const db = new FixtureDbModule(config.fixtures) as unknown as DbModule;
123
133
  sync = new SyncModule(db, undefined, config.syncOptions);
124
- fixtureSyncModuleCache.set(config.fixtures, sync);
134
+ cache.set(optionsKey, sync);
125
135
  }
126
136
  return sync;
127
137
  }
@@ -143,17 +153,10 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
143
153
  config.socketPort ?? "",
144
154
  config.socketBaseURL ?? "",
145
155
  config.siteName ?? "",
156
+ optionsKey,
146
157
  ].join("|");
147
158
  let sync = syncModuleCache.get(key);
148
159
  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
160
  const authConfig: AuthConfig | undefined = config.auth
158
161
  ? {
159
162
  useToken: config.auth.useToken,
@@ -580,6 +583,31 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
580
583
  // Connection / realtime hooks
581
584
  // ---------------------------------------------------------------------------
582
585
 
586
+ export interface UseFrappeCatchUpResult extends SyncCatchUpState {
587
+ retry: () => Promise<SyncCatchUpState>;
588
+ }
589
+
590
+ const idleCatchUpState: SyncCatchUpState = Object.freeze({
591
+ refreshing: false,
592
+ stale: false,
593
+ error: null,
594
+ });
595
+
596
+ /** Connection-level refresh qualification. Retry resolves with state, including failures. */
597
+ export function useFrappeCatchUp(
598
+ config: UseFrappeCollectionConfig | undefined,
599
+ ): UseFrappeCatchUpResult {
600
+ const sync = getFrappeSyncModule(config);
601
+ const subscribe = useCallback(
602
+ (listener: () => void) => sync?.subscribeStore(listener) ?? (() => {}),
603
+ [sync],
604
+ );
605
+ const snapshot = useCallback(() => sync?.getCatchUpState() ?? idleCatchUpState, [sync]);
606
+ const state = useSyncExternalStore(subscribe, snapshot, () => idleCatchUpState);
607
+ const retry = useCallback(() => sync?.catchUp() ?? Promise.resolve(idleCatchUpState), [sync]);
608
+ return useMemo(() => ({ ...state, retry }), [state, retry]);
609
+ }
610
+
583
611
  /** Snapshot returned by {@link useFrappeConnection}. */
584
612
  export interface FrappeConnectionState {
585
613
  /** Socket connected right now. */
@@ -605,14 +633,14 @@ export function useFrappeConnection(
605
633
  config: UseFrappeCollectionConfig | undefined,
606
634
  ): FrappeConnectionState {
607
635
  const [state, setState] = useState<FrappeConnectionState>(DISCONNECTED_STATE);
636
+ const syncModule =
637
+ config && !config.fixtures && config.realtime !== false ? getSyncModule(config) : undefined;
608
638
 
609
639
  useEffect(() => {
610
- if (!config || config.fixtures || config.realtime === false || !isBrowser()) {
640
+ if (!syncModule) {
611
641
  setState(DISCONNECTED_STATE);
612
642
  return;
613
643
  }
614
- const syncModule = getSyncModule(config);
615
- if (!syncModule) return;
616
644
 
617
645
  const read = () => {
618
646
  const next = syncModule.getConnectionState();
@@ -625,7 +653,7 @@ export function useFrappeConnection(
625
653
  read();
626
654
  return syncModule.subscribeStore(read);
627
655
  // eslint-disable-next-line react-hooks/exhaustive-deps
628
- }, [config?.baseURL, config?.realtime, !!config?.fixtures]);
656
+ }, [syncModule]);
629
657
 
630
658
  return config?.realtime === false ? DISCONNECTED_STATE : state;
631
659
  }
@@ -646,18 +674,12 @@ export function useFrappeDoctypeEvents(
646
674
  ): void {
647
675
  const handlerRef = useRef(onEvent);
648
676
  handlerRef.current = onEvent;
677
+ const syncModule =
678
+ enabled && config && !config.fixtures && config.realtime !== false && doctype
679
+ ? getSyncModule(config)
680
+ : undefined;
649
681
 
650
682
  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
683
  if (!syncModule) return;
662
684
 
663
685
  syncModule.watchDoctype(doctype);
@@ -672,7 +694,7 @@ export function useFrappeDoctypeEvents(
672
694
  syncModule.unwatchDoctype(doctype);
673
695
  };
674
696
  // eslint-disable-next-line react-hooks/exhaustive-deps
675
- }, [enabled, config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
697
+ }, [syncModule, doctype]);
676
698
  }
677
699
 
678
700
  // ---------------------------------------------------------------------------
@@ -712,17 +734,16 @@ export function useFrappePendingMutations(
712
734
  ): UseFrappePendingMutationsResult {
713
735
  const [entries, setEntries] = useState<DebugMutationEntry[]>(EMPTY_LEDGER);
714
736
  const syncModuleRef = useRef<SyncModule | undefined>(undefined);
737
+ const syncModule = getFrappeSyncModule(config);
715
738
 
716
739
  useEffect(() => {
717
- if (!config || !isBrowser()) {
740
+ if (!syncModule) {
718
741
  setEntries(EMPTY_LEDGER);
719
742
  return;
720
743
  }
721
- const syncModule = getSyncModule(config);
722
- if (!syncModule) return;
723
744
  syncModuleRef.current = syncModule;
724
745
 
725
- let lastSignature = "";
746
+ let lastSignature: string | undefined;
726
747
  const read = () => {
727
748
  const next = syncModule.getMutationEntries(doctype);
728
749
  const signature = next.map((e) => `${e.id}:${e.status}:${e.retryCount}`).join("|");
@@ -731,9 +752,13 @@ export function useFrappePendingMutations(
731
752
  setEntries(next.length === 0 ? EMPTY_LEDGER : next);
732
753
  };
733
754
  read();
734
- return syncModule.subscribeStore(read);
755
+ const unsubscribe = syncModule.subscribeStore(read);
756
+ return () => {
757
+ unsubscribe();
758
+ syncModuleRef.current = undefined;
759
+ };
735
760
  // eslint-disable-next-line react-hooks/exhaustive-deps
736
- }, [config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
761
+ }, [syncModule, doctype]);
737
762
 
738
763
  const retry = useCallback(async () => {
739
764
  await syncModuleRef.current?.replayPending();
@@ -1039,12 +1064,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
1039
1064
 
1040
1065
  // Stable serialised key for filters so we can depend on it in useEffect
1041
1066
  const filtersKey = JSON.stringify(filters);
1067
+ const syncModule = getFrappeSyncModule(config);
1042
1068
 
1043
1069
  // Subscribe via SyncModule with progressive: true
1044
1070
  useEffect(() => {
1045
- if (!config || !isBrowser()) return;
1046
-
1047
- const syncModule = getSyncModule(config);
1048
1071
  if (!syncModule) return;
1049
1072
  syncModuleRef.current = syncModule;
1050
1073
 
@@ -1100,9 +1123,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
1100
1123
  syncModule.unsubscribe(subscriptionIdRef.current);
1101
1124
  subscriptionIdRef.current = undefined;
1102
1125
  }
1126
+ syncModuleRef.current = undefined;
1103
1127
  };
1104
1128
  // eslint-disable-next-line react-hooks/exhaustive-deps
1105
- }, [config?.baseURL, config?.realtime, config?.auth?.token, doctype, filtersKey, pageSize]);
1129
+ }, [syncModule, config?.auth?.token, doctype, filtersKey, pageSize]);
1106
1130
 
1107
1131
  const loadMore = useCallback(async () => {
1108
1132
  const syncModule = syncModuleRef.current;
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export type { OptimisticRpcContext, OptimisticRpcOptions } from "./optimistic";
47
47
  // React hooks for Frappe + TanStack DB
48
48
  export {
49
49
  useFrappeCollection,
50
+ useFrappeCatchUp,
50
51
  useFrappeConnection,
51
52
  useFrappeDoctypeEvents,
52
53
  useFrappePendingMutations,
@@ -57,6 +58,7 @@ export {
57
58
  } from "./frappeReact";
58
59
  export type {
59
60
  FrappeConnectionState,
61
+ UseFrappeCatchUpResult,
60
62
  UseFrappeCollectionConfig,
61
63
  UseFrappePendingMutationsResult,
62
64
  UseInfiniteScrollConfig,
@@ -191,6 +193,7 @@ export type { FrappeDocValue, FrappeDocData, FrappeDoc, ChildTableRow } from "./
191
193
  // Sync types with advanced features
192
194
  export type {
193
195
  SyncOptions,
196
+ SyncCatchUpState,
194
197
  PageBoundaries,
195
198
  RehydrationStrategy,
196
199
  Mutation,
@@ -0,0 +1,142 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { HttpClient } from "../core/http";
3
+ import { DbModule } from "../db";
4
+ import { SyncModule } from "./index";
5
+ import { BackfillScope, type SyncOptions } from "./types";
6
+
7
+ const modules: SyncModule[] = [];
8
+ const row = (name: string) => ({ name, doctype: "Task", modified: "2026-09-09T00:00:00Z" });
9
+ function setup(options: SyncOptions = {}) {
10
+ const transport = vi.fn(async (_url: string) => Response.json({ data: [row("a")] }));
11
+ vi.stubGlobal("fetch", transport);
12
+ const sync = new SyncModule(
13
+ new DbModule(new HttpClient("https://catch-up.test", undefined, { maxRetries: 0 }), {
14
+ appName: "live.live",
15
+ }),
16
+ undefined,
17
+ options,
18
+ );
19
+ modules.push(sync);
20
+ return { sync, transport };
21
+ }
22
+ function deferred<T>() {
23
+ let resolve!: (value: T) => void;
24
+ const promise = new Promise<T>((done) => {
25
+ resolve = done;
26
+ });
27
+ return { promise, resolve };
28
+ }
29
+ afterEach(async () => {
30
+ for (const sync of modules.splice(0)) await sync.dispose();
31
+ vi.unstubAllGlobals();
32
+ vi.useRealTimers();
33
+ });
34
+
35
+ it("refreshes resource snapshots through focus, online and the optional interval", async () => {
36
+ vi.useFakeTimers();
37
+ const { sync, transport } = setup({ catchUpStrategy: "snapshot", cdcInterval: 1000 });
38
+ const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
39
+ expect(sync.getCatchUpState()).toEqual({ refreshing: false, stale: false, error: null });
40
+ for (const trigger of ["focus", "online", "interval"]) {
41
+ transport.mockResolvedValue(Response.json({ data: [row(trigger)] }));
42
+ if (trigger === "interval") await vi.advanceTimersByTimeAsync(1000);
43
+ else window.dispatchEvent(new Event(trigger));
44
+ await vi.waitFor(() =>
45
+ expect(sync.getDocsForSubscription(subscriptionId)[0]?.name).toBe(trigger),
46
+ );
47
+ await vi.waitFor(() => expect(sync.getCatchUpState().refreshing).toBe(false));
48
+ }
49
+ expect(
50
+ transport.mock.calls.every(([url]) => new URL(url).pathname === "/api/resource/Task"),
51
+ ).toBe(true);
52
+ expect(transport).toHaveBeenCalledTimes(4);
53
+ });
54
+
55
+ it("retains rows and stale/error qualification through a deferred retry", async () => {
56
+ const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
57
+ const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
58
+ transport.mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
59
+ const failed = await sync.catchUp();
60
+ expect(failed).toMatchObject({ refreshing: false, stale: true });
61
+ expect(failed.error).toBeInstanceOf(AggregateError);
62
+ expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a")]);
63
+ const read = deferred<Response>();
64
+ transport.mockImplementation(() => read.promise);
65
+ const retry = sync.catchUp();
66
+ expect(sync.getCatchUpState()).toMatchObject({
67
+ refreshing: true,
68
+ stale: true,
69
+ error: failed.error,
70
+ });
71
+ read.resolve(Response.json({ data: [] }));
72
+ expect(await retry).toEqual({ refreshing: false, stale: false, error: null });
73
+ expect(sync.getDocsForSubscription(subscriptionId)).toEqual([]);
74
+ });
75
+
76
+ it("keeps CDC as default, observes its error and preserves explicit backfill rejection", async () => {
77
+ const { sync, transport } = setup();
78
+ const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
79
+ transport.mockImplementation(async () => Response.json({ message: "no CDC" }, { status: 404 }));
80
+ const result = await sync.catchUp();
81
+ expect(result).toMatchObject({ stale: true, error: { status: 404 } });
82
+ expect(new URL(transport.mock.calls[1][0]).pathname).toBe("/api/method/live.live.api.backfill");
83
+ await expect(sync.backfill({ scope: BackfillScope.All })).rejects.toMatchObject({ status: 404 });
84
+ transport.mockImplementation(async () =>
85
+ Response.json({
86
+ message: {
87
+ rows: [row("b")],
88
+ deleted: [{ name: "a" }],
89
+ checkpoint: "next",
90
+ has_more: false,
91
+ },
92
+ }),
93
+ );
94
+ expect(await sync.catchUp()).toEqual({ refreshing: false, stale: false, error: null });
95
+ expect(sync.getDocsForSubscription(subscriptionId).map((doc) => doc.name)).toEqual(["b"]);
96
+ expect(sync.getSubscription(subscriptionId)?.checkpoint).toBe("next");
97
+ });
98
+
99
+ it("rejects progressive snapshot subscriptions before any fetch", async () => {
100
+ const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
101
+ await expect(sync.subscribe({ doctype: "Task", progressive: true })).rejects.toThrow(
102
+ /progressive/,
103
+ );
104
+ expect(transport).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("coalesces overlap into at most one trailing request and settles despite more events", async () => {
108
+ const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
109
+ await sync.subscribe({ doctype: "Task" });
110
+ const first = deferred<Response>();
111
+ const last = deferred<Response>();
112
+ transport.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => last.promise);
113
+ const one = sync.catchUp();
114
+ await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
115
+ expect(sync.catchUp()).toBe(one);
116
+ expect(sync.catchUp()).toBe(one);
117
+ first.resolve(Response.json({ data: [row("first")] }));
118
+ await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(3));
119
+ for (let i = 0; i < 20; i++) expect(sync.catchUp()).toBe(one);
120
+ last.resolve(Response.json({ data: [row("last")] }));
121
+ expect(await one).toEqual({ refreshing: false, stale: false, error: null });
122
+ expect(transport).toHaveBeenCalledTimes(3);
123
+ });
124
+
125
+ it.each(["reset", "dispose"])("does not publish a late failed catch-up after %s", async (end) => {
126
+ const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
127
+ await sync.subscribe({ doctype: "Task" });
128
+ const read = deferred<Response>();
129
+ transport.mockImplementation(() => read.promise);
130
+ const pending = sync.catchUp();
131
+ await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
132
+ if (end === "reset") sync.resetCache();
133
+ else await sync.dispose();
134
+ const afterReset = sync.getCatchUpState();
135
+ const listener = vi.fn();
136
+ const unsubscribe = sync.subscribeStore(listener);
137
+ read.resolve(Response.json({ message: "obsolete" }, { status: 503 }));
138
+ await pending;
139
+ expect(sync.getCatchUpState()).toBe(afterReset);
140
+ expect(listener).not.toHaveBeenCalled();
141
+ unsubscribe();
142
+ });