@multiplatform.one/frappe 7.6.3 → 7.7.1

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 (51) hide show
  1. package/dist/cjs/fixtureDb.cjs +8 -0
  2. package/dist/cjs/fixtureDb.native.js +10 -0
  3. package/dist/cjs/fixtureDb.native.js.map +1 -1
  4. package/dist/cjs/sync/virtualPageManager.cjs +2 -1
  5. package/dist/cjs/sync/virtualPageManager.native.js +4 -1
  6. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  7. package/dist/cjs/useLiveQuery.cjs +2 -8
  8. package/dist/cjs/useLiveQuery.native.js +2 -9
  9. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  10. package/dist/esm/fixtureDb.mjs +8 -0
  11. package/dist/esm/fixtureDb.mjs.map +1 -1
  12. package/dist/esm/fixtureDb.native.js +10 -0
  13. package/dist/esm/fixtureDb.native.js.map +1 -1
  14. package/dist/esm/sync/virtualPageManager.mjs +2 -1
  15. package/dist/esm/sync/virtualPageManager.mjs.map +1 -1
  16. package/dist/esm/sync/virtualPageManager.native.js +4 -1
  17. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  18. package/dist/esm/useLiveQuery.mjs +2 -7
  19. package/dist/esm/useLiveQuery.mjs.map +1 -1
  20. package/dist/esm/useLiveQuery.native.js +2 -8
  21. package/dist/esm/useLiveQuery.native.js.map +1 -1
  22. package/dist/jsx/fixtureDb.mjs +8 -0
  23. package/dist/jsx/fixtureDb.mjs.map +1 -1
  24. package/dist/jsx/fixtureDb.native.js +10 -0
  25. package/dist/jsx/fixtureDb.native.js.map +1 -1
  26. package/dist/jsx/sync/virtualPageManager.mjs +2 -1
  27. package/dist/jsx/sync/virtualPageManager.mjs.map +1 -1
  28. package/dist/jsx/sync/virtualPageManager.native.js +4 -1
  29. package/dist/jsx/sync/virtualPageManager.native.js.map +1 -1
  30. package/dist/jsx/useLiveQuery.mjs +2 -7
  31. package/dist/jsx/useLiveQuery.mjs.map +1 -1
  32. package/dist/jsx/useLiveQuery.native.js +2 -9
  33. package/dist/jsx/useLiveQuery.native.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/devtools/FrappeDevtoolsPanel.spec.tsx +270 -0
  36. package/src/devtools/FrappeDevtoolsPanel.stories.tsx +233 -0
  37. package/src/devtools/plugin.spec.tsx +91 -0
  38. package/src/devtools/plugin.stories.tsx +77 -0
  39. package/src/fixtureDb.spec.ts +126 -0
  40. package/src/fixtureDb.ts +16 -0
  41. package/src/sync/virtualPageManager.spec.ts +62 -0
  42. package/src/sync/virtualPageManager.ts +8 -2
  43. package/src/useFrappeCollection.stories.tsx +166 -0
  44. package/src/useFrappeInfiniteList.stories.tsx +104 -0
  45. package/src/useFrappePendingMutations.spec.ts +241 -0
  46. package/src/useFrappePendingMutations.stories.tsx +102 -0
  47. package/src/useLiveQuery.spec.ts +43 -0
  48. package/src/useLiveQuery.ts +16 -15
  49. package/types/fixtureDb.d.ts.map +1 -1
  50. package/types/sync/virtualPageManager.d.ts.map +1 -1
  51. package/types/useLiveQuery.d.ts.map +1 -1
@@ -0,0 +1,77 @@
1
+ import { useState } from "react";
2
+ import { isWeb, Text, YStack } from "tamagui";
3
+ import {
4
+ type ToDo,
5
+ toDoFixtureData,
6
+ useSeededJournal,
7
+ useStoryConfig,
8
+ } from "../../tests/storyFixtures";
9
+ import { useFrappeCollection } from "../frappeReact";
10
+ import { useLiveQuery } from "../useLiveQuery";
11
+ import { frappeDevtoolsPlugin } from "./plugin";
12
+
13
+ export default {
14
+ title: "Frappe/frappeDevtoolsPlugin",
15
+ tags: ["!test"],
16
+ parameters: {
17
+ status: { type: "stable" },
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "The plugin factory core hands to the TanStack Devtools shell (MPO-8): frappeDevtoolsPlugin(syncModule) returns { id: 'frappe', name: 'Frappe', render }. The shell lists id/name in its tab strip and calls render(hostElement, { theme }) when the tab opens; render returns the FrappeDevtoolsPanel element bound to that SyncModule. The story calls render the same way, with a real host element and the shell's theme prop. Network-hermetic (G-15/D-05): the SyncModule runs over an InMemoryFixtureProvider with one ToDo subscription and a seeded journal; nothing is fetched. On native there is no shell, so the story shows what the shell would list.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ function PluginHost({ theme }: { theme: "light" | "dark" }) {
28
+ const config = useStoryConfig(toDoFixtureData());
29
+ const collection = useFrappeCollection<ToDo>(config, "ToDo");
30
+ const { isReady } = useLiveQuery(
31
+ (q) => (collection ? q.from({ todo: collection }) : null),
32
+ [collection],
33
+ );
34
+ const syncModule = useSeededJournal(config, Boolean(isReady));
35
+ const [host, setHost] = useState<HTMLDivElement | null>(null);
36
+ if (!syncModule) {
37
+ return (
38
+ <Text fontSize="$2">
39
+ No SyncModule: getFrappeSyncModule() returns undefined during server rendering.
40
+ </Text>
41
+ );
42
+ }
43
+ const plugin = frappeDevtoolsPlugin(syncModule);
44
+ return (
45
+ <YStack gap="$2">
46
+ <Text fontSize="$2" data-testid="plugin-identity">
47
+ plugin.id = {plugin.id} · plugin.name = {plugin.name} · theme = {theme}
48
+ </Text>
49
+ {isWeb ? (
50
+ <div
51
+ ref={setHost}
52
+ data-testid="devtools-plugin-host"
53
+ style={{
54
+ height: 440,
55
+ borderRadius: 6,
56
+ overflow: "hidden",
57
+ border: "1px solid rgba(128, 128, 128, 0.35)",
58
+ }}
59
+ >
60
+ {host ? plugin.render(host, { theme }) : null}
61
+ </div>
62
+ ) : (
63
+ <Text fontSize="$2" color="$color10">
64
+ render() returns the DOM panel for the TanStack Devtools shell, which only exists on web.
65
+ The id and name above are what the shell lists.
66
+ </Text>
67
+ )}
68
+ </YStack>
69
+ );
70
+ }
71
+
72
+ /** render(host, { theme: "light" }), the way the shell opens the tab. */
73
+ export const Main = () => <PluginHost theme="light" />;
74
+ Main.storyName = "Main";
75
+
76
+ /** The same plugin rendered with the shell's dark theme prop. */
77
+ export const Dark = () => <PluginHost theme="dark" />;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * FixtureDbModule cursor paging.
3
+ *
4
+ * Fixture mode is what makes the frappe stories network-hermetic (G-15/D-05),
5
+ * so it has to page the same way the HTTP path does. It did not: `getDocList`
6
+ * forwarded fields / filters / orderBy / limitStart / limit and dropped
7
+ * `cursor` on the floor, so every cursor-paged fetch returned page ONE again.
8
+ * VirtualPageManager appends what it gets, so a fixture-backed
9
+ * useFrappeInfiniteList grew [1..10, 1..10] and React logged a duplicate-key
10
+ * error for every row of the first page (MPO-197).
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import { FixtureDbModule } from "./fixtureDb";
14
+ import { InMemoryFixtureProvider } from "./fixtures";
15
+ import { Order } from "./sync/types";
16
+
17
+ function pad(n: number): string {
18
+ return String(n).padStart(4, "0");
19
+ }
20
+
21
+ function todos(count: number) {
22
+ return Array.from({ length: count }, (_, i) => ({
23
+ name: `TODO-${pad(i + 1)}`,
24
+ doctype: "ToDo",
25
+ description: `row ${i + 1}`,
26
+ status: i % 2 === 0 ? "Open" : "Working",
27
+ }));
28
+ }
29
+
30
+ function db(count = 25) {
31
+ return new FixtureDbModule(new InMemoryFixtureProvider({ ToDo: todos(count) }));
32
+ }
33
+
34
+ const byName = { field: "name", order: Order.Asc };
35
+
36
+ describe("FixtureDbModule.getDocList cursor paging", () => {
37
+ it("with no cursor it returns the first page", async () => {
38
+ const rows = await db().getDocList("ToDo", { orderBy: byName, limit: 10 });
39
+ expect(rows.map((r) => r.name)).toEqual(todos(10).map((r) => r.name));
40
+ });
41
+
42
+ it("a cursor starts the page AFTER that document", async () => {
43
+ const rows = await db().getDocList("ToDo", {
44
+ orderBy: byName,
45
+ limit: 10,
46
+ cursor: "TODO-0010",
47
+ });
48
+ expect(rows[0]?.name).toBe("TODO-0011");
49
+ expect(rows).toHaveLength(10);
50
+ expect(rows.at(-1)?.name).toBe("TODO-0020");
51
+ });
52
+
53
+ it("paging the whole list by cursor yields every row exactly once", async () => {
54
+ const module = db(25);
55
+ const seen: string[] = [];
56
+ let cursor: string | undefined;
57
+ for (let page = 0; page < 5; page++) {
58
+ const rows = await module.getDocList("ToDo", { orderBy: byName, limit: 10, cursor });
59
+ if (rows.length === 0) break;
60
+ seen.push(...rows.map((r) => String(r.name)));
61
+ cursor = String(rows[rows.length - 1].name);
62
+ }
63
+ expect(seen).toHaveLength(25);
64
+ expect(new Set(seen).size).toBe(25);
65
+ expect(seen[0]).toBe("TODO-0001");
66
+ expect(seen.at(-1)).toBe("TODO-0025");
67
+ });
68
+
69
+ it("the last page is short and the one after it is empty", async () => {
70
+ const module = db(25);
71
+ const tail = await module.getDocList("ToDo", {
72
+ orderBy: byName,
73
+ limit: 10,
74
+ cursor: "TODO-0020",
75
+ });
76
+ expect(tail).toHaveLength(5);
77
+ const past = await module.getDocList("ToDo", {
78
+ orderBy: byName,
79
+ limit: 10,
80
+ cursor: "TODO-0025",
81
+ });
82
+ expect(past).toHaveLength(0);
83
+ });
84
+
85
+ it("a cursor naming a document that is not there falls back to the first page", async () => {
86
+ const rows = await db().getDocList("ToDo", { orderBy: byName, limit: 5, cursor: "NOPE" });
87
+ expect(rows[0]?.name).toBe("TODO-0001");
88
+ });
89
+
90
+ it("the cursor is resolved against the FILTERED order, not the whole doctype", async () => {
91
+ const rows = await db(10).getDocList("ToDo", {
92
+ orderBy: byName,
93
+ filters: { status: "Open" },
94
+ limit: 2,
95
+ cursor: "TODO-0003",
96
+ });
97
+ // Open rows are the odd names; after TODO-0003 come TODO-0005 and TODO-0007.
98
+ expect(rows.map((r) => r.name)).toEqual(["TODO-0005", "TODO-0007"]);
99
+ });
100
+
101
+ it("an explicit limitStart wins over the cursor (that caller pages by offset)", async () => {
102
+ const rows = await db().getDocList("ToDo", {
103
+ orderBy: byName,
104
+ limit: 3,
105
+ limitStart: 0,
106
+ cursor: "TODO-0010",
107
+ });
108
+ expect(rows[0]?.name).toBe("TODO-0001");
109
+ });
110
+
111
+ it("getListWithCursor and getDocList agree on where a cursor lands", async () => {
112
+ const module = db();
113
+ const viaList = await module.getDocList("ToDo", {
114
+ orderBy: byName,
115
+ limit: 5,
116
+ cursor: "TODO-0005",
117
+ });
118
+ const viaCursor = await module.getListWithCursor({
119
+ doctype: "ToDo",
120
+ orderBy: byName,
121
+ limit: 5,
122
+ cursor: "TODO-0005",
123
+ });
124
+ expect(viaList.map((r) => r.name)).toEqual(viaCursor.data.map((r) => r.name));
125
+ });
126
+ });
package/src/fixtureDb.ts CHANGED
@@ -68,6 +68,22 @@ export class FixtureDbModule {
68
68
  if (typeof options.limitStart === "number") fixtureOptions.limitStart = options.limitStart;
69
69
  if (options.limit != null) fixtureOptions.limit = options.limit;
70
70
 
71
+ // Honour `cursor` the way getListWithCursor does. Dropping it silently
72
+ // made every cursor-paged fetch return page ONE again, and
73
+ // VirtualPageManager.fetchMorePages appends what it gets without
74
+ // de-duplicating — so a fixture-backed useFrappeInfiniteList grew
75
+ // [1..10, 1..10] and React logged a duplicate-key error per row
76
+ // (Frappe/useFrappeInfiniteList "Sentinel Auto Load", MPO-197).
77
+ // An explicit limitStart wins: that caller is paging by offset already.
78
+ if (options.cursor && typeof options.limitStart !== "number") {
79
+ const ordered = await this.fixtures.getList(doctype, {
80
+ filters: options.filters as any,
81
+ orderBy: options.orderBy ? normalizeOrderBy(options.orderBy) : undefined,
82
+ } as any);
83
+ const cursorIdx = ordered.findIndex((d: any) => d.name === options.cursor);
84
+ if (cursorIdx >= 0) fixtureOptions.limitStart = cursorIdx + 1;
85
+ }
86
+
71
87
  const docs = await this.fixtures.getList<Data>(doctype, fixtureOptions as any);
72
88
  return docs.map((d) => ({ ...d, doctype, name: (d as any).name }) as FrappeDoc<Data>);
73
89
  }
@@ -822,4 +822,66 @@ describe("VirtualPageManager", () => {
822
822
  expect((virtualPageManager as any).shouldLimitCacheSize(subscription)).toBe(true);
823
823
  });
824
824
  });
825
+
826
+ /**
827
+ * docIds is a list of document NAMES, so a name can appear at most once.
828
+ * fetchMorePages used to append whatever came back without checking, which
829
+ * two racing loadMore() calls (the useFrappeInfiniteList sentinel fires
830
+ * twice before isLoadingMore settles) turned into the same page twice —
831
+ * React then logs a duplicate-key error per row (MPO-197).
832
+ */
833
+ describe("progressive cache stays a set of unique names", () => {
834
+ async function subscribeTen(id: string) {
835
+ await virtualPageManager.initializeSubscription(id, {
836
+ doctype: "TestDoc",
837
+ limit: 10,
838
+ orderBy: { field: "value", order: Order.Asc },
839
+ });
840
+ return virtualPageManager.getSubscription(id)!;
841
+ }
842
+
843
+ it("re-fetching from the same cursor does not duplicate a page", async () => {
844
+ const subscription = await subscribeTen("sub-dedupe-1");
845
+ // Rewind to one page, as a fresh subscribe leaves it, and point the
846
+ // cursor at the last row of that page.
847
+ setCache(subscription, {
848
+ docIds: mockDocs.slice(0, 10).map((d) => d.name),
849
+ nextCursor: "doc-9",
850
+ hasMore: true,
851
+ });
852
+ await virtualPageManager.getNextBatch("sub-dedupe-1", "doc-9", 10);
853
+ const first = [...getCache(subscription).docIds];
854
+ expect(new Set(first).size).toBe(first.length);
855
+
856
+ // A second call from the SAME cursor: the engine must not append the
857
+ // rows it already holds.
858
+ setCache(subscription, { nextCursor: "doc-9", hasMore: true });
859
+ await virtualPageManager.getNextBatch("sub-dedupe-1", "doc-9", 10);
860
+ const after = getCache(subscription).docIds;
861
+ expect(new Set(after).size).toBe(after.length);
862
+ });
863
+
864
+ it("two concurrent getNextBatch calls leave no duplicate ids", async () => {
865
+ const subscription = await subscribeTen("sub-dedupe-2");
866
+ setCache(subscription, {
867
+ docIds: mockDocs.slice(0, 10).map((d) => d.name),
868
+ nextCursor: "doc-9",
869
+ hasMore: true,
870
+ });
871
+ await Promise.all([
872
+ virtualPageManager.getNextBatch("sub-dedupe-2", "doc-9", 10),
873
+ virtualPageManager.getNextBatch("sub-dedupe-2", "doc-9", 10),
874
+ ]);
875
+ const ids = getCache(subscription).docIds;
876
+ expect(new Set(ids).size).toBe(ids.length);
877
+ });
878
+
879
+ it("de-duplicating never reorders or drops the rows already held", async () => {
880
+ const subscription = await subscribeTen("sub-dedupe-3");
881
+ const head = mockDocs.slice(0, 10).map((d) => d.name);
882
+ setCache(subscription, { docIds: [...head], nextCursor: "doc-9", hasMore: true });
883
+ await virtualPageManager.getNextBatch("sub-dedupe-3", "doc-9", 10);
884
+ expect(getCache(subscription).docIds.slice(0, 10)).toEqual(head);
885
+ });
886
+ });
825
887
  });
@@ -848,9 +848,15 @@ export class VirtualPageManager<Data extends FrappeDocData = FrappeDocData> {
848
848
  hasMore = response.length === fetchSize && fetchedDocs.length < neededCount;
849
849
  }
850
850
 
851
- // Append to progressive cache
852
- const newDocIds = fetchedDocs.map((doc) => doc.name);
851
+ // Append to progressive cache. docIds is a list of document NAMES and a
852
+ // name can only appear once, so filter out any already present: two
853
+ // loadMore() calls that race past the isLoadingMore guard (the sentinel
854
+ // path fires twice before the state settles) both fetch from the same
855
+ // cursor, and a blind append put the same page in twice — which React
856
+ // reports as a duplicate-key error per row (MPO-197).
853
857
  const currentDocIds = subscription.getProgressiveCache().docIds;
858
+ const seen = new Set(currentDocIds);
859
+ const newDocIds = fetchedDocs.map((doc) => doc.name).filter((name) => !seen.has(name));
854
860
  const updatedDocIds = [...currentDocIds, ...newDocIds];
855
861
  subscription.updateProgressiveCache({
856
862
  docIds: updatedDocIds,
@@ -0,0 +1,166 @@
1
+ import { useState } from "react";
2
+ import { Button, Text, XStack, YStack } from "tamagui";
3
+ import {
4
+ type ToDo,
5
+ type ToDoStatus,
6
+ toDoFixtureData,
7
+ useStoryConfig,
8
+ } from "../tests/storyFixtures";
9
+ import type { FrappeCollectionOptionsConfig } from "./collection";
10
+ import { type UseFrappeCollectionConfig, useFrappeCollection } from "./frappeReact";
11
+ import { Order } from "./sync/types";
12
+ import { useLiveQuery } from "./useLiveQuery";
13
+
14
+ export default {
15
+ title: "Frappe/useFrappeCollection",
16
+ tags: ["!test"],
17
+ parameters: {
18
+ status: { type: "stable" },
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "The SDK's main React entry point: useFrappeCollection(config, doctype, options) returns a TanStack DB collection kept in sync by the SyncModule, and useLiveQuery reads it. Writes go through collection.insert / update / delete, apply optimistically, and are journaled by the engine. The probe prints the collection status, the live-query state and the rows, with buttons that write through the collection. Network-hermetic (G-15/D-05): config.fixtures is an InMemoryFixtureProvider, so the same SyncModule pipeline runs over in-memory documents and nothing is fetched.",
23
+ },
24
+ },
25
+ },
26
+ };
27
+
28
+ type ProbeOptions = Omit<FrappeCollectionOptionsConfig<ToDo>, "sync">;
29
+
30
+ interface ProbeProps {
31
+ config: UseFrappeCollectionConfig | undefined;
32
+ doctype?: string;
33
+ options?: ProbeOptions;
34
+ }
35
+
36
+ const nextStatus: Record<ToDoStatus, ToDoStatus> = {
37
+ Open: "Working",
38
+ Working: "Completed",
39
+ Completed: "Open",
40
+ Cancelled: "Open",
41
+ };
42
+
43
+ function swallow(tx: { isPersisted: { promise: Promise<unknown> } }) {
44
+ tx.isPersisted.promise.catch(() => {});
45
+ }
46
+
47
+ function CollectionProbe({ config, doctype = "ToDo", options }: ProbeProps) {
48
+ const collection = useFrappeCollection<ToDo>(config, doctype, options);
49
+ const { data, isReady, isLoading } = useLiveQuery(
50
+ (q) => (collection ? q.from({ todo: collection }) : null),
51
+ [collection],
52
+ );
53
+ const [inserted, setInserted] = useState(0);
54
+ const rows = (data as ToDo[] | undefined) ?? [];
55
+
56
+ const insert = () => {
57
+ if (!collection) return;
58
+ const n = inserted + 1;
59
+ setInserted(n);
60
+ swallow(
61
+ collection.insert({
62
+ name: `TODO-NEW-${String(n).padStart(3, "0")}`,
63
+ doctype,
64
+ description: `Added from the story (#${n})`,
65
+ status: "Open",
66
+ priority: "Medium",
67
+ date: "2026-09-05",
68
+ modified: "2026-09-05 12:00:00",
69
+ }),
70
+ );
71
+ };
72
+ const advanceFirst = () => {
73
+ const first = rows[0];
74
+ if (!collection || !first) return;
75
+ swallow(
76
+ collection.update(first.name, (draft) => {
77
+ draft.status = nextStatus[first.status];
78
+ }),
79
+ );
80
+ };
81
+ const removeLast = () => {
82
+ const last = rows[rows.length - 1];
83
+ if (!collection || !last) return;
84
+ swallow(collection.delete(last.name));
85
+ };
86
+
87
+ return (
88
+ <YStack gap="$3" data-testid="collection-probe">
89
+ <Text fontWeight="600">
90
+ useFrappeCollection(config, "{doctype}"{options ? ", options" : ""}) + useLiveQuery
91
+ </Text>
92
+ <Text fontSize="$2" data-testid="collection-status">
93
+ collection: {collection ? collection.status : "(undefined, no config)"} · live query:{" "}
94
+ {isLoading ? "loading" : isReady ? "ready" : "idle"} · rows: {rows.length}
95
+ </Text>
96
+ <XStack gap="$2" flexWrap="wrap">
97
+ <Button size="$2" onPress={insert} disabled={!collection}>
98
+ Insert
99
+ </Button>
100
+ <Button size="$2" onPress={advanceFirst} disabled={!rows.length}>
101
+ Advance first row's status
102
+ </Button>
103
+ <Button size="$2" onPress={removeLast} disabled={!rows.length}>
104
+ Delete last row
105
+ </Button>
106
+ </XStack>
107
+ <YStack gap="$1">
108
+ {rows.map((row) => (
109
+ <XStack key={row.name} gap="$3" alignItems="center" data-testid={`row-${row.name}`}>
110
+ <Text fontSize="$2" width={120}>
111
+ {row.name}
112
+ </Text>
113
+ <Text fontSize="$2" flex={1}>
114
+ {row.description}
115
+ </Text>
116
+ <Text fontSize="$2" width={90}>
117
+ {row.status}
118
+ </Text>
119
+ <Text fontSize="$2" width={70}>
120
+ {row.priority}
121
+ </Text>
122
+ <Text fontSize="$2" width={90}>
123
+ {row.date}
124
+ </Text>
125
+ </XStack>
126
+ ))}
127
+ {collection && isReady && rows.length === 0 ? (
128
+ <Text fontSize="$2" color="$color10">
129
+ No documents match.
130
+ </Text>
131
+ ) : null}
132
+ </YStack>
133
+ </YStack>
134
+ );
135
+ }
136
+
137
+ /** Eight ToDos from fixtures; insert, update and delete through the collection. */
138
+ export const Main = () => {
139
+ const config = useStoryConfig(toDoFixtureData(8, { scripted: false }));
140
+ return <CollectionProbe config={config} />;
141
+ };
142
+ Main.storyName = "Main";
143
+
144
+ /** filters, orderBy and limit are passed to the subscription: only Open rows, by date, at most four. */
145
+ export const Filtered = () => {
146
+ const config = useStoryConfig(toDoFixtureData(12, { scripted: false }));
147
+ return (
148
+ <CollectionProbe
149
+ config={config}
150
+ options={{
151
+ filters: [["status", "=", "Open"]],
152
+ orderBy: { field: "date", order: Order.Asc },
153
+ limit: 4,
154
+ }}
155
+ />
156
+ );
157
+ };
158
+
159
+ /** A doctype with no fixture rows: the collection still reaches ready, with zero rows. */
160
+ export const EmptyDoctype = () => {
161
+ const config = useStoryConfig(toDoFixtureData(8, { scripted: false }));
162
+ return <CollectionProbe config={config} doctype="Note" />;
163
+ };
164
+
165
+ /** No config (no provider above the consumer): the hook returns undefined and nothing syncs. */
166
+ export const WithoutConfig = () => <CollectionProbe config={undefined} />;
@@ -0,0 +1,104 @@
1
+ import { Button, isWeb, Text, XStack, YStack } from "tamagui";
2
+ import { type ToDo, toDoFixtureData, useStoryConfig } from "../tests/storyFixtures";
3
+ import { type UseFrappeCollectionConfig, useFrappeInfiniteList } from "./frappeReact";
4
+ import { Order } from "./sync/types";
5
+
6
+ export default {
7
+ title: "Frappe/useFrappeInfiniteList",
8
+ tags: ["!test"],
9
+ parameters: {
10
+ status: { type: "stable" },
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "Cursor-paged list over a progressive SyncModule subscription: items, hasMore, isLoading / isLoadingMore, loadMore(), refresh(), and a sentinelRef that loads the next page when its element scrolls into view (IntersectionObserver, web only). The probe pages 57 fixture ToDos ten at a time. Network-hermetic (G-15/D-05): config.fixtures is an InMemoryFixtureProvider and the cursor path runs through FixtureDbModule.getListWithCursor, so nothing is fetched.",
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ interface ProbeProps {
21
+ config: UseFrappeCollectionConfig | undefined;
22
+ pageSize: number;
23
+ /** Attach sentinelRef to a trailing element so pages load as you scroll (web only). */
24
+ sentinel?: boolean;
25
+ }
26
+
27
+ function InfiniteProbe({ config, pageSize, sentinel = false }: ProbeProps) {
28
+ const list = useFrappeInfiniteList<ToDo>({
29
+ config,
30
+ doctype: "ToDo",
31
+ pageSize,
32
+ orderBy: { field: "name", order: Order.Asc },
33
+ });
34
+ return (
35
+ <YStack gap="$3" data-testid="infinite-probe">
36
+ <Text fontWeight="600">
37
+ useFrappeInfiniteList({`{ doctype: "ToDo", pageSize: ${pageSize} }`})
38
+ </Text>
39
+ <Text fontSize="$2" data-testid="infinite-status">
40
+ loaded {list.totalLoaded} · hasMore {String(list.hasMore)} · isLoading{" "}
41
+ {String(list.isLoading)} · isLoadingMore {String(list.isLoadingMore)}
42
+ {list.error ? ` · error: ${list.error.message}` : ""}
43
+ {!config ? " · no config, so nothing subscribes" : ""}
44
+ </Text>
45
+ <XStack gap="$2" flexWrap="wrap">
46
+ <Button
47
+ size="$2"
48
+ onPress={() => void list.loadMore()}
49
+ disabled={!list.hasMore || list.isLoadingMore || !config}
50
+ >
51
+ Load more
52
+ </Button>
53
+ <Button size="$2" onPress={() => void list.refresh()} disabled={!config}>
54
+ Refresh
55
+ </Button>
56
+ </XStack>
57
+ <YStack gap="$1">
58
+ {list.items.map((row) => (
59
+ <XStack key={row.name} gap="$3" alignItems="center" data-testid={`row-${row.name}`}>
60
+ <Text fontSize="$2" width={120}>
61
+ {row.name}
62
+ </Text>
63
+ <Text fontSize="$2" flex={1}>
64
+ {row.description}
65
+ </Text>
66
+ <Text fontSize="$2" width={90}>
67
+ {row.status}
68
+ </Text>
69
+ </XStack>
70
+ ))}
71
+ {sentinel && isWeb ? (
72
+ <div ref={list.sentinelRef} data-testid="infinite-sentinel" style={{ height: 1 }} />
73
+ ) : null}
74
+ {!list.hasMore && list.items.length > 0 ? (
75
+ <Text fontSize="$2" color="$color10">
76
+ End of list.
77
+ </Text>
78
+ ) : null}
79
+ </YStack>
80
+ </YStack>
81
+ );
82
+ }
83
+
84
+ /** 57 rows, ten per page; Load more walks the cursor until hasMore is false. */
85
+ export const Main = () => {
86
+ const config = useStoryConfig(toDoFixtureData(57, { scripted: false }));
87
+ return <InfiniteProbe config={config} pageSize={10} />;
88
+ };
89
+ Main.storyName = "Main";
90
+
91
+ /** Same list with sentinelRef attached: scroll to the bottom and the next page loads by itself (web). */
92
+ export const SentinelAutoLoad = () => {
93
+ const config = useStoryConfig(toDoFixtureData(57, { scripted: false }));
94
+ return <InfiniteProbe config={config} pageSize={10} sentinel />;
95
+ };
96
+
97
+ /** Fewer rows than one page: hasMore is false after the first load. */
98
+ export const SinglePage = () => {
99
+ const config = useStoryConfig(toDoFixtureData(8, { scripted: false }));
100
+ return <InfiniteProbe config={config} pageSize={20} />;
101
+ };
102
+
103
+ /** No config: the hook stays in its initial loading state and never subscribes. */
104
+ export const WithoutConfig = () => <InfiniteProbe config={undefined} pageSize={10} />;