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