@multiplatform.one/frappe 7.7.4 → 7.8.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 (117) hide show
  1. package/dist/cjs/collection.cjs +4 -1
  2. package/dist/cjs/collection.native.js +4 -1
  3. package/dist/cjs/collection.native.js.map +1 -1
  4. package/dist/cjs/doctype.cjs +25 -8
  5. package/dist/cjs/doctype.native.js +26 -9
  6. package/dist/cjs/doctype.native.js.map +1 -1
  7. package/dist/cjs/frappeReact.cjs +13 -9
  8. package/dist/cjs/frappeReact.native.js +13 -9
  9. package/dist/cjs/frappeReact.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/pacer.cjs +1 -0
  14. package/dist/cjs/pacer.native.js +1 -0
  15. package/dist/cjs/pacer.native.js.map +1 -1
  16. package/dist/cjs/sync/index.cjs +10 -1
  17. package/dist/cjs/sync/index.native.js +10 -1
  18. package/dist/cjs/sync/index.native.js.map +1 -1
  19. package/dist/cjs/sync/subscriptionManager.cjs +2 -4
  20. package/dist/cjs/sync/subscriptionManager.native.js +3 -4
  21. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  22. package/dist/cjs/sync/types.cjs +3 -1
  23. package/dist/cjs/sync/types.native.js +3 -1
  24. package/dist/cjs/sync/types.native.js.map +1 -1
  25. package/dist/esm/collection.mjs +5 -2
  26. package/dist/esm/collection.mjs.map +1 -1
  27. package/dist/esm/collection.native.js +5 -2
  28. package/dist/esm/collection.native.js.map +1 -1
  29. package/dist/esm/doctype.mjs +25 -8
  30. package/dist/esm/doctype.mjs.map +1 -1
  31. package/dist/esm/doctype.native.js +26 -9
  32. package/dist/esm/doctype.native.js.map +1 -1
  33. package/dist/esm/frappeReact.mjs +13 -9
  34. package/dist/esm/frappeReact.mjs.map +1 -1
  35. package/dist/esm/frappeReact.native.js +13 -9
  36. package/dist/esm/frappeReact.native.js.map +1 -1
  37. package/dist/esm/index.mjs +2 -2
  38. package/dist/esm/index.mjs.map +1 -1
  39. package/dist/esm/index.native.js +2 -2
  40. package/dist/esm/index.native.js.map +1 -1
  41. package/dist/esm/pacer.mjs +1 -0
  42. package/dist/esm/pacer.mjs.map +1 -1
  43. package/dist/esm/pacer.native.js +1 -0
  44. package/dist/esm/pacer.native.js.map +1 -1
  45. package/dist/esm/sync/index.mjs +10 -1
  46. package/dist/esm/sync/index.mjs.map +1 -1
  47. package/dist/esm/sync/index.native.js +10 -1
  48. package/dist/esm/sync/index.native.js.map +1 -1
  49. package/dist/esm/sync/subscriptionManager.mjs +3 -5
  50. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  51. package/dist/esm/sync/subscriptionManager.native.js +4 -5
  52. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  53. package/dist/esm/sync/types.mjs +2 -1
  54. package/dist/esm/sync/types.mjs.map +1 -1
  55. package/dist/esm/sync/types.native.js +2 -1
  56. package/dist/esm/sync/types.native.js.map +1 -1
  57. package/dist/jsx/collection.mjs +5 -2
  58. package/dist/jsx/collection.mjs.map +1 -1
  59. package/dist/jsx/collection.native.js +4 -1
  60. package/dist/jsx/collection.native.js.map +1 -1
  61. package/dist/jsx/doctype.mjs +25 -8
  62. package/dist/jsx/doctype.mjs.map +1 -1
  63. package/dist/jsx/doctype.native.js +26 -9
  64. package/dist/jsx/doctype.native.js.map +1 -1
  65. package/dist/jsx/frappeReact.mjs +13 -9
  66. package/dist/jsx/frappeReact.mjs.map +1 -1
  67. package/dist/jsx/frappeReact.native.js +13 -9
  68. package/dist/jsx/frappeReact.native.js.map +1 -1
  69. package/dist/jsx/index.js +2 -2
  70. package/dist/jsx/index.js.map +1 -1
  71. package/dist/jsx/index.mjs +2 -2
  72. package/dist/jsx/index.mjs.map +1 -1
  73. package/dist/jsx/index.native.js +1 -0
  74. package/dist/jsx/index.native.js.map +1 -1
  75. package/dist/jsx/pacer.mjs +1 -0
  76. package/dist/jsx/pacer.mjs.map +1 -1
  77. package/dist/jsx/pacer.native.js +1 -0
  78. package/dist/jsx/pacer.native.js.map +1 -1
  79. package/dist/jsx/sync/index.mjs +10 -1
  80. package/dist/jsx/sync/index.mjs.map +1 -1
  81. package/dist/jsx/sync/index.native.js +10 -1
  82. package/dist/jsx/sync/index.native.js.map +1 -1
  83. package/dist/jsx/sync/subscriptionManager.mjs +3 -5
  84. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  85. package/dist/jsx/sync/subscriptionManager.native.js +3 -4
  86. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  87. package/dist/jsx/sync/types.mjs +2 -1
  88. package/dist/jsx/sync/types.mjs.map +1 -1
  89. package/dist/jsx/sync/types.native.js +3 -1
  90. package/dist/jsx/sync/types.native.js.map +1 -1
  91. package/package.json +6 -6
  92. package/src/collection.ts +29 -4
  93. package/src/collectionLimitParity.spec.ts +135 -0
  94. package/src/devtools/FrappeDevtoolsPanel.spec.tsx +4 -2
  95. package/src/doctype.ts +47 -15
  96. package/src/frappeReact.spec.ts +15 -0
  97. package/src/frappeReact.ts +37 -10
  98. package/src/index.ts +1 -1
  99. package/src/pacer.ts +10 -3
  100. package/src/sync/index.ts +14 -1
  101. package/src/sync/subscriptionManager.spec.ts +41 -2
  102. package/src/sync/subscriptionManager.ts +8 -11
  103. package/src/sync/syncModule.spec.ts +78 -3
  104. package/src/sync/types.ts +10 -0
  105. package/types/collection.d.ts +24 -2
  106. package/types/collection.d.ts.map +1 -1
  107. package/types/doctype.d.ts.map +1 -1
  108. package/types/frappeReact.d.ts +8 -2
  109. package/types/frappeReact.d.ts.map +1 -1
  110. package/types/index.d.ts +1 -1
  111. package/types/index.d.ts.map +1 -1
  112. package/types/pacer.d.ts +8 -2
  113. package/types/pacer.d.ts.map +1 -1
  114. package/types/sync/index.d.ts.map +1 -1
  115. package/types/sync/subscriptionManager.d.ts.map +1 -1
  116. package/types/sync/types.d.ts +9 -0
  117. package/types/sync/types.d.ts.map +1 -1
package/src/collection.ts CHANGED
@@ -3,7 +3,7 @@ import { createCollection } from "@tanstack/db";
3
3
  import type { FrappeDocData } from "./common/types";
4
4
  import type { FixtureProvider } from "./fixtures";
5
5
  import type { SyncModule } from "./sync";
6
- import { Operation } from "./sync/types";
6
+ import { defaultPageLength, Operation } from "./sync/types";
7
7
  import type { SubscriptionQuery } from "./sync/types";
8
8
 
9
9
  /**
@@ -24,7 +24,11 @@ export interface FrappeCollectionOptionsConfig<
24
24
  id?: string;
25
25
  /** Order-by configuration */
26
26
  orderBy?: SubscriptionQuery["orderBy"];
27
- /** Limit for the subscription */
27
+ /**
28
+ * Rows to read. Omitting it reads {@link defaultPageLength} (20) rows in
29
+ * BOTH fixture mode and against a live bench. Pass `limit: 0` for every
30
+ * row, which also means the same on both.
31
+ */
28
32
  limit?: number;
29
33
  /**
30
34
  * Called when the initial subscription fetch fails (network down,
@@ -74,6 +78,13 @@ export function releaseFrappeCollectionSync(collection: object): boolean {
74
78
  * This follows TanStack DB's collection options creator pattern (Pattern B),
75
79
  * where mutation handlers are built-in and delegate to SyncModule.
76
80
  *
81
+ * ROW LIMIT. A collection created without `limit` reads
82
+ * {@link defaultPageLength} (20) rows, and reads the same 20 in fixture mode
83
+ * as it does against a live bench. Frappe clamps a request with no
84
+ * `limit_page_length` to 20 while a fixture provider returns every row, so
85
+ * the limit is now always sent rather than left off. Pass `limit: 0` for
86
+ * every row.
87
+ *
77
88
  * @typeParam TDoc - The Frappe document type
78
89
  * @param doctype - The Frappe doctype name (e.g. "ToDo", "Pokemon")
79
90
  * @param config - Configuration for the collection
@@ -249,6 +260,8 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
249
260
  * Creates a TanStack DB Collection backed by a Frappe doctype via SyncModule.
250
261
  *
251
262
  * Convenience wrapper that calls `createCollection(frappeCollectionOptions(...))`.
263
+ * Without `limit` it reads {@link defaultPageLength} (20) rows in fixture mode
264
+ * and against a live bench alike; `limit: 0` reads every row.
252
265
  *
253
266
  * @typeParam TDoc - The Frappe document type
254
267
  * @param doctype - The Frappe doctype name
@@ -295,7 +308,10 @@ export interface FixtureCollectionOptionsConfig<
295
308
  id?: string;
296
309
  /** Order-by configuration */
297
310
  orderBy?: SubscriptionQuery["orderBy"];
298
- /** Limit for the query */
311
+ /**
312
+ * Rows to read. Omitting it reads {@link defaultPageLength} (20) rows, the
313
+ * same as against a live bench. Pass `limit: 0` for every row.
314
+ */
299
315
  limit?: number;
300
316
  /** Offset for pagination */
301
317
  limitStart?: number;
@@ -304,6 +320,10 @@ export interface FixtureCollectionOptionsConfig<
304
320
  /**
305
321
  * Creates TanStack DB collection options wired to a FixtureProvider.
306
322
  * Used when config.fixtures is set (e.g. in tests or Storybook).
323
+ *
324
+ * ROW LIMIT. Without `limit` this reads {@link defaultPageLength} (20) rows,
325
+ * the same count a live bench returns for a request with no
326
+ * `limit_page_length`. Pass `limit: 0` for every row.
307
327
  */
308
328
  export function fixtureCollectionOptions<TDoc extends object & { name: string; doctype: string }>(
309
329
  doctype: string,
@@ -334,7 +354,10 @@ export function fixtureCollectionOptions<TDoc extends object & { name: string; d
334
354
  filters: filters as Partial<TDoc> | Array<[string, string, any]> | undefined,
335
355
  orderBy: orderByForFixture,
336
356
  limitStart,
337
- limit,
357
+ // Same page length the bench applies to a request that carries no
358
+ // limit_page_length, so a no-limit collection reads the same rows
359
+ // here as it does in production.
360
+ limit: limit ?? defaultPageLength,
338
361
  });
339
362
 
340
363
  if (cancelled) return;
@@ -393,6 +416,8 @@ export function fixtureCollectionOptions<TDoc extends object & { name: string; d
393
416
  /**
394
417
  * Creates a TanStack DB Collection backed by a FixtureProvider.
395
418
  * Used when config.fixtures is set (e.g. in tests or Storybook).
419
+ * Without `limit` it reads {@link defaultPageLength} (20) rows, matching a
420
+ * live bench; `limit: 0` reads every row.
396
421
  */
397
422
  export function createFixtureCollection<TDoc extends object & { name: string; doctype: string }>(
398
423
  doctype: string,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * A collection created with no `limit` must return the same rows in fixture
3
+ * mode as it does against a live bench (MPO-238). Frappe clamps a request
4
+ * that carries no `limit_page_length` to 20 rows while a fixture provider
5
+ * slices only when asked, so development read every row, production read 20,
6
+ * and neither one errored to say so.
7
+ */
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import { fixtureCollectionOptions } from "./collection";
10
+ import type { DbModule } from "./db";
11
+ import { FixtureDbModule } from "./fixtureDb";
12
+ import { InMemoryFixtureProvider } from "./fixtures";
13
+ import { NormalizedCache } from "./sync/normalizedCache";
14
+ import { SubscriptionManager } from "./sync/subscriptionManager";
15
+ import { defaultPageLength } from "./sync/types";
16
+ import type { SubscriptionQuery } from "./sync/types";
17
+
18
+ const rowCount = 50;
19
+
20
+ interface Todo {
21
+ name: string;
22
+ doctype: string;
23
+ description: string;
24
+ modified: string;
25
+ }
26
+
27
+ function todos(): Todo[] {
28
+ return Array.from({ length: rowCount }, (_, index) => ({
29
+ name: `TODO-${String(index + 1).padStart(4, "0")}`,
30
+ doctype: "ToDo",
31
+ description: `row ${index + 1}`,
32
+ modified: `2026-01-01T00:${String(index).padStart(2, "0")}:00`,
33
+ }));
34
+ }
35
+
36
+ /**
37
+ * Stands in for a live bench: /api/resource pages a request with no
38
+ * `limit_page_length` at 20 rows and reads `limit_page_length=0` as every
39
+ * row. Nothing else about the response differs from fixture mode, so any
40
+ * divergence these specs catch is the page length alone.
41
+ */
42
+ function benchDb(): DbModule {
43
+ const rows = todos();
44
+ return {
45
+ getDoc: async (_doctype: string, name: string) => rows.find((row) => row.name === name) ?? null,
46
+ getDocList: async (_doctype: string, options: { limit?: number; limitStart?: number } = {}) => {
47
+ const start = options.limitStart ?? 0;
48
+ const pageLength = options.limit ?? 20;
49
+ return rows.slice(start, pageLength === 0 ? undefined : start + pageLength);
50
+ },
51
+ } as unknown as DbModule;
52
+ }
53
+
54
+ function fixtureDb(): DbModule {
55
+ return new FixtureDbModule(new InMemoryFixtureProvider({ ToDo: todos() })) as unknown as DbModule;
56
+ }
57
+
58
+ async function subscribedNames(db: DbModule, query: SubscriptionQuery): Promise<string[]> {
59
+ const manager = new SubscriptionManager(db, new NormalizedCache());
60
+ const result = await manager.subscribe(query);
61
+ return result.rows.map((row) => String(row.name));
62
+ }
63
+
64
+ async function fixtureCollectionNames(limit?: number): Promise<string[]> {
65
+ const options = fixtureCollectionOptions<Todo>("ToDo", {
66
+ fixtures: new InMemoryFixtureProvider({ ToDo: todos() }),
67
+ ...(limit === undefined ? {} : { limit }),
68
+ });
69
+ const written: string[] = [];
70
+ const cleanup = options.sync!.sync({
71
+ begin: vi.fn(),
72
+ write: (message: any) => {
73
+ written.push(message.value.name);
74
+ },
75
+ commit: vi.fn(),
76
+ markReady: vi.fn(),
77
+ truncate: vi.fn(),
78
+ collection: {} as any,
79
+ } as any);
80
+ await new Promise((resolve) => setTimeout(resolve, 10));
81
+ if (typeof cleanup === "function") cleanup();
82
+ return written;
83
+ }
84
+
85
+ describe("collection row limit parity", () => {
86
+ it("reads the default page length with no limit, on both paths", async () => {
87
+ const query: SubscriptionQuery = { doctype: "ToDo" };
88
+
89
+ const live = await subscribedNames(benchDb(), query);
90
+ const fixture = await subscribedNames(fixtureDb(), query);
91
+
92
+ expect(fixture).toEqual(live);
93
+ expect(fixture).toHaveLength(defaultPageLength);
94
+ expect(fixture.at(-1)).toBe("TODO-0020");
95
+ });
96
+
97
+ it("reads every row with limit 0, on both paths", async () => {
98
+ const query: SubscriptionQuery = { doctype: "ToDo", limit: 0 };
99
+
100
+ const live = await subscribedNames(benchDb(), query);
101
+ const fixture = await subscribedNames(fixtureDb(), query);
102
+
103
+ expect(fixture).toEqual(live);
104
+ expect(fixture).toHaveLength(rowCount);
105
+ });
106
+
107
+ it("reads an explicit limit past the bench default, on both paths", async () => {
108
+ const query: SubscriptionQuery = { doctype: "ToDo", limit: 35 };
109
+
110
+ const live = await subscribedNames(benchDb(), query);
111
+ const fixture = await subscribedNames(fixtureDb(), query);
112
+
113
+ expect(fixture).toEqual(live);
114
+ expect(fixture).toHaveLength(35);
115
+ });
116
+
117
+ it("offsets by limitStart with no limit, on both paths", async () => {
118
+ const query: SubscriptionQuery = { doctype: "ToDo", limitStart: 20 };
119
+
120
+ const live = await subscribedNames(benchDb(), query);
121
+ const fixture = await subscribedNames(fixtureDb(), query);
122
+
123
+ expect(fixture).toEqual(live);
124
+ expect(fixture[0]).toBe("TODO-0021");
125
+ expect(fixture).toHaveLength(defaultPageLength);
126
+ });
127
+
128
+ it("gives the direct fixture collection the same default page length", async () => {
129
+ expect(await fixtureCollectionNames()).toHaveLength(defaultPageLength);
130
+ });
131
+
132
+ it("gives the direct fixture collection every row with limit 0", async () => {
133
+ expect(await fixtureCollectionNames(0)).toHaveLength(rowCount);
134
+ });
135
+ });
@@ -131,7 +131,9 @@ describe("FrappeDevtoolsPanel reads __debug()", () => {
131
131
  );
132
132
  expect(stub.module.__debug).toHaveBeenCalled();
133
133
  expect(container.textContent).toContain("ToDo");
134
+ expect(container.textContent).toContain("8 docs");
134
135
  expect(container.textContent).toContain("Note");
136
+ expect(container.textContent).toContain("3 docs");
135
137
  });
136
138
 
137
139
  it("an empty snapshot still paints the shell, not a blank div", () => {
@@ -262,8 +264,8 @@ describe("FrappeDevtoolsPanel theme prop", () => {
262
264
  );
263
265
  const darkBg = (dark.container.firstElementChild as HTMLElement).style.backgroundColor;
264
266
  expect(lightBg).not.toBe(darkBg);
265
- expect(lightBg).toBeTruthy();
266
- expect(darkBg).toBeTruthy();
267
+ expect(lightBg).toBe(themes.light.bg);
268
+ expect(darkBg).toBe(themes.dark.bg);
267
269
  // The panel owns its own palette (devtools/theme.ts); it never reads the app knobs.
268
270
  expect(Object.keys(themes)).toEqual(["light", "dark"]);
269
271
  });
package/src/doctype.ts CHANGED
@@ -372,7 +372,8 @@ export function parseFormLayout(fields: DocTypeField[]): FormSection[] {
372
372
  // Internal helpers
373
373
  // ---------------------------------------------------------------------------
374
374
 
375
- function createHttpClient(config: FrappeConnectionConfig): HttpClient {
375
+ function createHttpClient(config: FrappeConnectionConfig): HttpClient | undefined {
376
+ if (!config.baseURL) return undefined;
376
377
  const authConfig: AuthConfig | undefined = config.auth
377
378
  ? {
378
379
  useToken: config.auth.useToken,
@@ -474,6 +475,27 @@ function parseRawDocTypeMeta(raw: RawDocTypeResponse): DocTypeMeta {
474
475
  };
475
476
  }
476
477
 
478
+ /**
479
+ * Cache identity for a connection. A fixtures-only connection has no
480
+ * baseURL, so it is keyed by its FixtureProvider instance instead — two
481
+ * corpora must not share one cached DocType (the cross-test pollution the
482
+ * SyncModule cache already avoids the same way).
483
+ */
484
+ const fixtureConnectionIds = new WeakMap<object, string>();
485
+ let nextFixtureConnectionId = 0;
486
+
487
+ function connectionKey(config: FrappeConnectionConfig): string {
488
+ if (config.baseURL) return config.baseURL;
489
+ const fixtures = config.fixtures;
490
+ if (!fixtures) return "";
491
+ let id = fixtureConnectionIds.get(fixtures);
492
+ if (!id) {
493
+ id = `fixtures#${++nextFixtureConnectionId}`;
494
+ fixtureConnectionIds.set(fixtures, id);
495
+ }
496
+ return id;
497
+ }
498
+
477
499
  /**
478
500
  * Setup pattern for a stable HttpClient ref, following the same pattern
479
501
  * used in `useDebouncedSearch` for DbModule.
@@ -538,7 +560,7 @@ export function useDocTypeMeta(
538
560
  error: Error | null;
539
561
  refetch: () => void;
540
562
  } {
541
- const cacheKey = config ? `${config.baseURL}:${doctype}` : "";
563
+ const cacheKey = config ? `${connectionKey(config)}:${doctype}` : "";
542
564
 
543
565
  const [meta, setMeta] = useState<DocTypeMeta | undefined>(() =>
544
566
  cacheKey ? metaCache.get(cacheKey) : undefined,
@@ -571,12 +593,14 @@ export function useDocTypeMeta(
571
593
  return;
572
594
  }
573
595
 
574
- const key = `${config.baseURL}:${doctype}`;
596
+ const key = `${connectionKey(config)}:${doctype}`;
575
597
  // Already have cached data (synchronized during render)
576
598
  if (metaCache.has(key)) return;
577
599
 
578
600
  const http = httpRef.current;
579
- if (!http) return;
601
+ // No client AND no corpus: nothing can answer, so park rather than
602
+ // dial `undefined` (a fixtures-only host has no baseURL by design).
603
+ if (!http && !config.fixtures) return;
580
604
 
581
605
  let cancelled = false;
582
606
  setIsLoading(true);
@@ -596,6 +620,14 @@ export function useDocTypeMeta(
596
620
  let raw = fromFixtures?.fields ? fromFixtures : undefined;
597
621
 
598
622
  if (!raw) {
623
+ if (!http) {
624
+ // Fixtures-only host: there is no server behind the corpus, so a
625
+ // missing DocType entry is the honest end of the road.
626
+ throw new Error(
627
+ `DocType "${doctype}" is not in the fixture corpus and no baseURL is configured`,
628
+ );
629
+ }
630
+
599
631
  // `getdoctype` is the whitelisted RPC the Desk itself loads form
600
632
  // metadata with, and every logged-in user may call it. Reading
601
633
  // `/api/resource/DocType/<name>` instead asks for the DocType
@@ -612,20 +644,20 @@ export function useDocTypeMeta(
612
644
  // docs[0] is the requested doctype; docs[1..] are its child tables.
613
645
  const docs = response.payload?.docs;
614
646
  raw = docs?.find((doc) => doc?.name === doctype) ?? docs?.[0];
615
- }
616
647
 
617
- if (!raw) {
618
- // Sites that expose the REST API without the desk leave the RPC
619
- // above unregistered, so it answers with no `docs`. The document
620
- // route still serves meta there, and such a site's API clients are
621
- // typically System Managers, who may read it.
622
- const rest = await http.get<{ data?: RawDocTypeResponse }>(
623
- `/api/resource/DocType/${encodeURIComponent(doctype)}`,
624
- );
648
+ if (!raw) {
649
+ // Sites that expose the REST API without the desk leave the RPC
650
+ // above unregistered, so it answers with no `docs`. The document
651
+ // route still serves meta there, and such a site's API clients are
652
+ // typically System Managers, who may read it.
653
+ const rest = await http.get<{ data?: RawDocTypeResponse }>(
654
+ `/api/resource/DocType/${encodeURIComponent(doctype)}`,
655
+ );
625
656
 
626
- if (cancelled) return;
657
+ if (cancelled) return;
627
658
 
628
- raw = rest.payload?.data;
659
+ raw = rest.payload?.data;
660
+ }
629
661
  }
630
662
 
631
663
  if (!raw) {
@@ -1073,3 +1073,18 @@ describe("useInfiniteScroll", () => {
1073
1073
  expect(ioInstances[0].disconnect).toHaveBeenCalled();
1074
1074
  });
1075
1075
  });
1076
+
1077
+ describe("explicit HTTP-only realtime mode", () => {
1078
+ it("isolates the disabled engine while default and true share realtime", async () => {
1079
+ const { getFrappeSyncModule } = await import("./frappeReact");
1080
+ mockRealtimeCtor.mockClear();
1081
+ const baseURL = "https://realtime-mode-cache.test";
1082
+ const http = getFrappeSyncModule({ baseURL, realtime: false });
1083
+ expect(mockRealtimeCtor).not.toHaveBeenCalled();
1084
+ const enabled = getFrappeSyncModule({ baseURL });
1085
+ expect(mockRealtimeCtor).toHaveBeenCalledTimes(1);
1086
+ expect(getFrappeSyncModule({ baseURL, realtime: true })).toBe(enabled);
1087
+ expect(getFrappeSyncModule({ baseURL, realtime: false })).toBe(http);
1088
+ expect(http).not.toBe(enabled);
1089
+ });
1090
+ });
@@ -20,8 +20,14 @@ import { isBrowser } from "./util";
20
20
  * Configuration for creating a Frappe collection with connection details.
21
21
  */
22
22
  export interface UseFrappeCollectionConfig {
23
- /** The base URL of the Frappe server */
24
- baseURL: string;
23
+ /**
24
+ * The base URL of the Frappe server. Required unless `fixtures` is set —
25
+ * a fixture corpus is the whole data source and nothing is ever dialled,
26
+ * so a fixtures-only host legitimately has no server to name.
27
+ */
28
+ baseURL?: string;
29
+ /** False keeps HTTP sync without constructing a realtime transport. Defaults to true. */
30
+ realtime?: boolean;
25
31
  /**
26
32
  * Override the port used for the Socket.IO realtime connection.
27
33
  * In dev, set to `FRAPPE_SOCKETIO_PORT` (e.g. 9000). Omit in production
@@ -120,6 +126,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
120
126
  return sync;
121
127
  }
122
128
 
129
+ // No fixtures and no baseURL: there is nothing to dial. Same contract as
130
+ // SSR above — consumers render their loading/empty state instead of
131
+ // constructing an HttpClient against `undefined`.
132
+ if (!config.baseURL) return undefined;
133
+
123
134
  // Key on the full socket target, not just baseURL: socketPort /
124
135
  // socketBaseURL / siteName all change where (or how) the realtime socket
125
136
  // dials, so configs that differ in them must not share a SyncModule —
@@ -128,6 +139,7 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
128
139
  // (identical fields → identical key).
129
140
  const key = [
130
141
  config.baseURL,
142
+ config.realtime === false ? "http" : "realtime",
131
143
  config.socketPort ?? "",
132
144
  config.socketBaseURL ?? "",
133
145
  config.siteName ?? "",
@@ -164,7 +176,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
164
176
  auth: authConfig,
165
177
  socketOptions: config.socketOptions,
166
178
  };
167
- sync = new SyncModule(db, new RealtimeModule(realtimeConfig), config.syncOptions);
179
+ sync = new SyncModule(
180
+ db,
181
+ config.realtime === false ? undefined : new RealtimeModule(realtimeConfig),
182
+ config.syncOptions,
183
+ );
168
184
  syncModuleCache.set(key, sync);
169
185
  }
170
186
  return sync;
@@ -591,7 +607,10 @@ export function useFrappeConnection(
591
607
  const [state, setState] = useState<FrappeConnectionState>(DISCONNECTED_STATE);
592
608
 
593
609
  useEffect(() => {
594
- if (!config || config.fixtures || !isBrowser()) return;
610
+ if (!config || config.fixtures || config.realtime === false || !isBrowser()) {
611
+ setState(DISCONNECTED_STATE);
612
+ return;
613
+ }
595
614
  const syncModule = getSyncModule(config);
596
615
  if (!syncModule) return;
597
616
 
@@ -606,9 +625,9 @@ export function useFrappeConnection(
606
625
  read();
607
626
  return syncModule.subscribeStore(read);
608
627
  // eslint-disable-next-line react-hooks/exhaustive-deps
609
- }, [config?.baseURL, !!config?.fixtures]);
628
+ }, [config?.baseURL, config?.realtime, !!config?.fixtures]);
610
629
 
611
- return state;
630
+ return config?.realtime === false ? DISCONNECTED_STATE : state;
612
631
  }
613
632
 
614
633
  /**
@@ -629,7 +648,15 @@ export function useFrappeDoctypeEvents(
629
648
  handlerRef.current = onEvent;
630
649
 
631
650
  useEffect(() => {
632
- if (!enabled || !config || config.fixtures || !doctype || !isBrowser()) return;
651
+ if (
652
+ !enabled ||
653
+ !config ||
654
+ config.fixtures ||
655
+ config.realtime === false ||
656
+ !doctype ||
657
+ !isBrowser()
658
+ )
659
+ return;
633
660
  const syncModule = getSyncModule(config);
634
661
  if (!syncModule) return;
635
662
 
@@ -645,7 +672,7 @@ export function useFrappeDoctypeEvents(
645
672
  syncModule.unwatchDoctype(doctype);
646
673
  };
647
674
  // eslint-disable-next-line react-hooks/exhaustive-deps
648
- }, [enabled, config?.baseURL, !!config?.fixtures, doctype]);
675
+ }, [enabled, config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
649
676
  }
650
677
 
651
678
  // ---------------------------------------------------------------------------
@@ -706,7 +733,7 @@ export function useFrappePendingMutations(
706
733
  read();
707
734
  return syncModule.subscribeStore(read);
708
735
  // eslint-disable-next-line react-hooks/exhaustive-deps
709
- }, [config?.baseURL, !!config?.fixtures, doctype]);
736
+ }, [config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
710
737
 
711
738
  const retry = useCallback(async () => {
712
739
  await syncModuleRef.current?.replayPending();
@@ -1075,7 +1102,7 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
1075
1102
  }
1076
1103
  };
1077
1104
  // eslint-disable-next-line react-hooks/exhaustive-deps
1078
- }, [config?.baseURL, config?.auth?.token, doctype, filtersKey, pageSize]);
1105
+ }, [config?.baseURL, config?.realtime, config?.auth?.token, doctype, filtersKey, pageSize]);
1079
1106
 
1080
1107
  const loadMore = useCallback(async () => {
1081
1108
  const syncModule = syncModuleRef.current;
package/src/index.ts CHANGED
@@ -201,7 +201,7 @@ export type {
201
201
  DebugMutationEntry,
202
202
  PageIsland,
203
203
  } from "./sync/types";
204
- export { Order, Operation } from "./sync/types";
204
+ export { defaultPageLength, Order, Operation } from "./sync/types";
205
205
  export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
206
206
  export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
207
207
 
package/src/pacer.ts CHANGED
@@ -53,8 +53,14 @@ export interface DebouncedSearchResult<TDoc extends FrappeDocData> {
53
53
  * Connection configuration for hooks that need a Frappe backend.
54
54
  */
55
55
  export interface FrappeConnectionConfig {
56
- /** The base URL of the Frappe server */
57
- baseURL: string;
56
+ /**
57
+ * The base URL of the Frappe server. Required unless `fixtures` is set:
58
+ * a fixtures-only host has no server, and the hooks here read the corpus
59
+ * (or park) instead of dialling one.
60
+ */
61
+ baseURL?: string;
62
+ /** False disables realtime when this connection is used by collection hooks. */
63
+ realtime?: boolean;
58
64
  /** Override port for Socket.IO realtime connection (e.g. 9000 in dev). */
59
65
  socketPort?: number;
60
66
  /** Authentication configuration */
@@ -72,7 +78,8 @@ export interface FrappeConnectionConfig {
72
78
  fixtures?: FixtureProvider;
73
79
  }
74
80
 
75
- function createDbModule(config: FrappeConnectionConfig): DbModule {
81
+ function createDbModule(config: FrappeConnectionConfig): DbModule | undefined {
82
+ if (!config.baseURL) return undefined;
76
83
  const authConfig: AuthConfig | undefined = config.auth
77
84
  ? {
78
85
  useToken: config.auth.useToken,
package/src/sync/index.ts CHANGED
@@ -27,6 +27,19 @@ export interface SyncRealtimeEvent {
27
27
  payload?: unknown;
28
28
  }
29
29
 
30
+ function getBrowserJournalStorage(): Storage | undefined {
31
+ if (typeof window === "undefined" || typeof document === "undefined") return undefined;
32
+ try {
33
+ const storage = window.localStorage;
34
+ return typeof storage?.getItem === "function" && typeof storage.setItem === "function"
35
+ ? storage
36
+ : undefined;
37
+ } catch {
38
+ // Browsers can deny access; Node's Web Storage is not browser-scoped.
39
+ return undefined;
40
+ }
41
+ }
42
+
30
43
  /**
31
44
  * Synchronization engine that provides Zero-style UX on top of Frappe's REST API.
32
45
  *
@@ -110,7 +123,7 @@ export class SyncModule {
110
123
  this.mutationJournal = new MutationJournal({
111
124
  ttl: this.options.mutationTTL,
112
125
  enableAutoCleanup: true,
113
- storage: typeof localStorage !== "undefined" ? localStorage : undefined,
126
+ storage: getBrowserJournalStorage(),
114
127
  });
115
128
  this.subscriptionManager = new SubscriptionManager(this.db, this.normalizedCache, this.options);
116
129
 
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import type { FrappeDoc, FrappeDocData } from "../common/types";
3
3
  import { SubscriptionManager } from "./subscriptionManager";
4
- import { BackfillScope } from "./types";
4
+ import { BackfillScope, defaultPageLength } from "./types";
5
5
  import { Order } from "./types";
6
6
  import type { SubscriptionQuery, SyncOptions } from "./types";
7
7
 
@@ -156,7 +156,10 @@ describe("SubscriptionManager", () => {
156
156
  });
157
157
  });
158
158
 
159
- it("omits limit from the initial fetch when the query has none", async () => {
159
+ it("sends the default page length when the query has no limit", async () => {
160
+ // MPO-238: leaving limit_page_length off let the bench clamp to 20
161
+ // while fixture mode returned every row, so a no-limit collection read
162
+ // one count in development and another in production.
160
163
  const query: SubscriptionQuery = {
161
164
  doctype: "TestDoc",
162
165
  fields: ["title"],
@@ -168,6 +171,42 @@ describe("SubscriptionManager", () => {
168
171
  fields: ["title", "modified", "name"],
169
172
  filters: undefined,
170
173
  orderBy: undefined,
174
+ limit: defaultPageLength,
175
+ });
176
+ });
177
+
178
+ it("passes limit 0 through as Frappe's every-row page length", async () => {
179
+ const query: SubscriptionQuery = {
180
+ doctype: "TestDoc",
181
+ fields: ["title"],
182
+ limit: 0,
183
+ };
184
+
185
+ await subscriptionManager.subscribe(query);
186
+
187
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
188
+ fields: ["title", "modified", "name"],
189
+ filters: undefined,
190
+ orderBy: undefined,
191
+ limit: 0,
192
+ });
193
+ });
194
+
195
+ it("applies limitStart without an explicit limit", async () => {
196
+ const query: SubscriptionQuery = {
197
+ doctype: "TestDoc",
198
+ fields: ["title"],
199
+ limitStart: 20,
200
+ };
201
+
202
+ await subscriptionManager.subscribe(query);
203
+
204
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
205
+ fields: ["title", "modified", "name"],
206
+ filters: undefined,
207
+ orderBy: undefined,
208
+ limit: defaultPageLength,
209
+ limitStart: 20,
171
210
  });
172
211
  });
173
212
 
@@ -4,7 +4,7 @@ import type { DbModule } from "../db";
4
4
  import { BackfillManager } from "./backfill";
5
5
  import { extractSingleName, matchesFilters } from "./filters";
6
6
  import type { NormalizedCache } from "./normalizedCache";
7
- import { BackfillScope, Order } from "./types";
7
+ import { BackfillScope, defaultPageLength, Order } from "./types";
8
8
  import type {
9
9
  BackfillArgs,
10
10
  OrderByField,
@@ -168,16 +168,13 @@ export class SubscriptionManager {
168
168
  filters: query.filters,
169
169
  orderBy: query.orderBy,
170
170
  };
171
- // Honor an explicit subscription limit on the INITIAL fetch. Without
172
- // this the request carries no limit_page_length and the bench clamps
173
- // to its default page length (20) — collections created with
174
- // `limit: N` silently truncated to 20 rows (surfaced by the pokemon
175
- // moves accordion once rosters grew past 20 junction rows). No
176
- // explicit limit keeps the previous no-param behavior.
177
- if (query.limit != null) {
178
- listOptions.limit = query.limit;
179
- if (query.limitStart != null) listOptions.limitStart = query.limitStart;
180
- }
171
+ // Always send a page length on the INITIAL fetch. A request with no
172
+ // limit_page_length is clamped to 20 rows by the bench while a fixture
173
+ // provider returns every row, so an absent limit made development and
174
+ // production disagree with nothing on screen to say so. `limit: 0`
175
+ // (every row) passes through untouched.
176
+ listOptions.limit = query.limit ?? defaultPageLength;
177
+ if (query.limitStart != null) listOptions.limitStart = query.limitStart;
181
178
  if (this.options.useCursorPagination) listOptions.cursor = undefined;
182
179
  const response = await this.db.getDocList<Data>(query.doctype, listOptions);
183
180