@multiplatform.one/frappe 7.11.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 +1 -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 +13 -1
- package/dist/cjs/frappeReact.native.js +20 -2
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +6 -0
- package/dist/cjs/index.native.js +8 -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 +8 -0
- package/dist/cjs/sync/index.native.js +10 -0
- 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.native.js.map +1 -1
- 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 +13 -1
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +19 -1
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +3 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +3 -2
- 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 +8 -0
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +10 -0
- 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.native.js.map +1 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/syncOptionsKey.native.js.map +1 -1
- 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 +13 -1
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +20 -2
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +3 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +3 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +8 -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 +8 -0
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +10 -0
- 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.native.js.map +1 -1
- package/dist/jsx/sync/virtualPageManager.native.js.map +1 -1
- package/dist/jsx/syncOptionsKey.native.js.map +1 -1
- 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 +9 -9
- package/src/doctype.spec.ts +148 -5
- package/src/doctype.ts +202 -173
- package/src/fixtures.ts +1 -0
- package/src/frappeReact.hydration.spec.tsx +164 -0
- package/src/frappeReact.spec.ts +56 -0
- package/src/frappeReact.ts +31 -5
- package/src/index.ts +11 -0
- package/src/serverLoaders.ts +77 -0
- package/src/sync/index.ts +9 -0
- package/src/sync/rehydration.ts +5 -0
- package/src/sync/syncModule.spec.ts +20 -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 +3 -2
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +3 -2
- 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 +6 -0
- 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/useLiveQuery.d.ts +3 -13
- package/types/useLiveQuery.d.ts.map +1 -1
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
|
@@ -77,12 +77,25 @@ const fixtureSyncModuleCache = new WeakMap<
|
|
|
77
77
|
import("./fixtures").FixtureProvider,
|
|
78
78
|
Map<string, SyncModule>
|
|
79
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
|
+
}
|
|
80
90
|
|
|
81
91
|
/**
|
|
82
|
-
* 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.
|
|
83
94
|
* Intended for devtools integration — not part of the public API.
|
|
84
95
|
*/
|
|
85
96
|
export function __getActiveSyncModule(): SyncModule | undefined {
|
|
97
|
+
const latest = latestSyncModule?.deref();
|
|
98
|
+
if (latest) return latest;
|
|
86
99
|
for (const sync of syncModuleCache.values()) return sync;
|
|
87
100
|
return undefined;
|
|
88
101
|
}
|
|
@@ -132,6 +145,7 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
132
145
|
const db = new FixtureDbModule(config.fixtures) as unknown as DbModule;
|
|
133
146
|
sync = new SyncModule(db, undefined, config.syncOptions);
|
|
134
147
|
cache.set(optionsKey, sync);
|
|
148
|
+
rememberLatestSyncModule(sync);
|
|
135
149
|
}
|
|
136
150
|
return sync;
|
|
137
151
|
}
|
|
@@ -185,6 +199,7 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
185
199
|
config.syncOptions,
|
|
186
200
|
);
|
|
187
201
|
syncModuleCache.set(key, sync);
|
|
202
|
+
rememberLatestSyncModule(sync);
|
|
188
203
|
}
|
|
189
204
|
return sync;
|
|
190
205
|
}
|
|
@@ -465,6 +480,10 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
|
|
|
465
480
|
entry.gcTimer = setTimeout(tick, SHARED_COLLECTION_GC_MS);
|
|
466
481
|
}
|
|
467
482
|
|
|
483
|
+
const subscribeHydration = () => () => {};
|
|
484
|
+
const clientCollectionAvailable = () => true;
|
|
485
|
+
const serverCollectionAvailable = () => false;
|
|
486
|
+
|
|
468
487
|
/**
|
|
469
488
|
* React hook that returns a TanStack DB Collection backed by a Frappe doctype.
|
|
470
489
|
*
|
|
@@ -488,7 +507,7 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
|
|
|
488
507
|
* @param config - Connection configuration (baseURL, auth)
|
|
489
508
|
* @param doctype - The Frappe doctype name
|
|
490
509
|
* @param options - Optional collection configuration (fields, filters, etc.)
|
|
491
|
-
* @returns A stable
|
|
510
|
+
* @returns A stable collection, or undefined during SSR/hydration or without config
|
|
492
511
|
*
|
|
493
512
|
* @example
|
|
494
513
|
* ```tsx
|
|
@@ -515,6 +534,13 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
|
|
|
515
534
|
doctype: string,
|
|
516
535
|
options?: Omit<FrappeCollectionOptionsConfig<TDoc>, "sync">,
|
|
517
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
|
+
);
|
|
518
544
|
// Consumers pass inline onSyncError closures; keep one stable wrapper per
|
|
519
545
|
// hook instance so the entry's handler registry doesn't churn every render.
|
|
520
546
|
const errorHandlerRef = useRef(options?.onSyncError);
|
|
@@ -526,16 +552,16 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
|
|
|
526
552
|
const [, setStaleEntryBump] = useState(0);
|
|
527
553
|
|
|
528
554
|
// Resolve the shared entry during render so the collection is available on
|
|
529
|
-
// the
|
|
555
|
+
// the first client-only/native render and after hydration. An empty doctype is
|
|
530
556
|
// treated like an undefined config: consumers such as DynamicLink pass ""
|
|
531
557
|
// before a doctype is chosen, and syncing doctype "" fires a bogus
|
|
532
558
|
// `/api/v2/document/` 404. During SSR, getSyncModule returns undefined —
|
|
533
559
|
// the component renders a loading state and the client hydration pass
|
|
534
|
-
//
|
|
560
|
+
// exposes the real collection after this boundary hydrates.
|
|
535
561
|
let cache: Map<string, SharedCollectionEntry> | undefined;
|
|
536
562
|
let key = "";
|
|
537
563
|
let entry: SharedCollectionEntry | undefined;
|
|
538
|
-
if (config && doctype) {
|
|
564
|
+
if (collectionAvailable && config && doctype) {
|
|
539
565
|
const syncModule = getSyncModule(config);
|
|
540
566
|
if (syncModule) {
|
|
541
567
|
key = collectionOptionsKey(doctype, options);
|
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";
|
|
@@ -174,11 +181,15 @@ export {
|
|
|
174
181
|
useDocTypeSearch,
|
|
175
182
|
parseDependsOn,
|
|
176
183
|
parseFormLayout,
|
|
184
|
+
stockFieldTypes,
|
|
185
|
+
customFieldTypes,
|
|
186
|
+
frappeFieldTypes,
|
|
177
187
|
} from "./doctype";
|
|
178
188
|
export type {
|
|
179
189
|
DocTypeMeta,
|
|
180
190
|
DocTypeField,
|
|
181
191
|
DocTypePermission,
|
|
192
|
+
DocTypeLink,
|
|
182
193
|
FormSection,
|
|
183
194
|
FormColumn,
|
|
184
195
|
FrappeFieldType,
|
|
@@ -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
|
+
}
|
package/src/sync/index.ts
CHANGED
|
@@ -286,6 +286,15 @@ export class SyncModule {
|
|
|
286
286
|
this.emit();
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The name the server assigned a document this module created under
|
|
291
|
+
* `temporaryName`, once the create is acknowledged. Undefined while it is
|
|
292
|
+
* pending, or when the server kept the name it was given.
|
|
293
|
+
*/
|
|
294
|
+
createdName(temporaryName: string): string | undefined {
|
|
295
|
+
return this.rehydrationManager.resolveTempId(temporaryName);
|
|
296
|
+
}
|
|
297
|
+
|
|
289
298
|
/** Refresh active query snapshots in place. Failures retain the last committed data. */
|
|
290
299
|
async refreshSubscriptions(
|
|
291
300
|
options?: SubscriptionRefreshOptions,
|
package/src/sync/rehydration.ts
CHANGED
|
@@ -370,6 +370,11 @@ export class RehydrationManager {
|
|
|
370
370
|
this.temporaryIdMap.set(tempId, realName);
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/** The name the server gave a document created under a temp id. */
|
|
374
|
+
resolveTempId(tempId: string): string | undefined {
|
|
375
|
+
return this.temporaryIdMap.get(tempId);
|
|
376
|
+
}
|
|
377
|
+
|
|
373
378
|
/**
|
|
374
379
|
* Clears the rehydration queue.
|
|
375
380
|
*/
|
|
@@ -65,6 +65,26 @@ describe("SyncModule", () => {
|
|
|
65
65
|
return new SyncModule(mockDb, mockRealtime, options);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
describe("createdName (MPO-333)", () => {
|
|
69
|
+
it("names the document the server created under a temporary name", async () => {
|
|
70
|
+
mockDb.createDoc.mockResolvedValue({
|
|
71
|
+
name: "a1b2c3",
|
|
72
|
+
doctype: "TestDoc",
|
|
73
|
+
modified: "2024-01-01T11:00:00",
|
|
74
|
+
title: "hash named",
|
|
75
|
+
});
|
|
76
|
+
sync = createSync();
|
|
77
|
+
expect(sync.createdName("new-1")).toBeUndefined();
|
|
78
|
+
await sync.mutate({
|
|
79
|
+
mutationId: "create-hash",
|
|
80
|
+
operation: Operation.Create,
|
|
81
|
+
doc: { name: "new-1", doctype: "TestDoc", title: "hash named" },
|
|
82
|
+
});
|
|
83
|
+
expect(sync.createdName("new-1")).toBe("a1b2c3");
|
|
84
|
+
expect(sync.createdName("new-2")).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
68
88
|
describe("automatic journal storage", () => {
|
|
69
89
|
it("reads HTTP rows during SSR with Node's incomplete localStorage object", async () => {
|
|
70
90
|
vi.stubGlobal("window", undefined);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { hydrateRoot } from "react-dom/client";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const platform = vi.hoisted(() => ({ server: true }));
|
|
7
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
8
|
+
get isServer() {
|
|
9
|
+
return platform.server;
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
it.each([
|
|
14
|
+
{ warm: false, enabled: true },
|
|
15
|
+
{ warm: true, enabled: true },
|
|
16
|
+
{ warm: true, enabled: false },
|
|
17
|
+
])("hydrates real queries and preserves updates/cleanup (%j)", async ({ warm, enabled }) => {
|
|
18
|
+
platform.server = true;
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
const serverDb = await import("@tanstack/db");
|
|
21
|
+
const serverCollection = serverDb.createCollection(
|
|
22
|
+
serverDb.localOnlyCollectionOptions({
|
|
23
|
+
getKey: (row: { id: string; label: string }) => row.id,
|
|
24
|
+
initialData: [{ id: "alpha", label: "Alpha" }],
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
const { useLiveQuery: serverQuery } = await import("./useLiveQuery");
|
|
28
|
+
function ServerView() {
|
|
29
|
+
const result = serverQuery((q) => (enabled ? q.from({ item: serverCollection }) : undefined));
|
|
30
|
+
return (
|
|
31
|
+
<div data-testid="result">
|
|
32
|
+
{!result.isEnabled
|
|
33
|
+
? "Disabled"
|
|
34
|
+
: result.data?.map((row) => row.label).join(",") || "Loading"}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const container = document.createElement("div");
|
|
39
|
+
container.innerHTML = renderToString(<ServerView />);
|
|
40
|
+
document.body.append(container);
|
|
41
|
+
const serverNode = container.firstChild;
|
|
42
|
+
platform.server = false;
|
|
43
|
+
vi.resetModules();
|
|
44
|
+
const clientDb = await import("@tanstack/db");
|
|
45
|
+
const collection = clientDb.createCollection(
|
|
46
|
+
clientDb.localOnlyCollectionOptions({
|
|
47
|
+
getKey: (row: { id: string; label: string }) => row.id,
|
|
48
|
+
initialData: [{ id: "alpha", label: "Alpha" }],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
if (warm) await collection.preload();
|
|
52
|
+
const { useLiveQuery: clientQuery } = await import("./useLiveQuery");
|
|
53
|
+
let observedQuery: { subscriberCount: number; status: string } | undefined;
|
|
54
|
+
function ClientView({ active = enabled, selected }: { active?: boolean; selected?: string }) {
|
|
55
|
+
const result = clientQuery(
|
|
56
|
+
(q) => {
|
|
57
|
+
if (!active) return undefined;
|
|
58
|
+
const query = q.from({ item: collection });
|
|
59
|
+
return selected ? query.where(({ item }) => clientDb.eq(item.id, selected)) : query;
|
|
60
|
+
},
|
|
61
|
+
[active, selected],
|
|
62
|
+
);
|
|
63
|
+
observedQuery = result.collection;
|
|
64
|
+
return (
|
|
65
|
+
<div data-testid="result">
|
|
66
|
+
{!result.isEnabled
|
|
67
|
+
? "Disabled"
|
|
68
|
+
: result.data?.map((row) => row.label).join(",") || "Loading"}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const onRecoverableError = vi.fn();
|
|
73
|
+
let root: ReturnType<typeof hydrateRoot> | undefined;
|
|
74
|
+
try {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
root = hydrateRoot(container, <ClientView />, { onRecoverableError });
|
|
77
|
+
});
|
|
78
|
+
expect(
|
|
79
|
+
onRecoverableError.mock.calls.map(([error]) => error.cause?.message || error.message),
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
expect(container.firstChild).toBe(serverNode);
|
|
82
|
+
await vi.waitFor(() => expect(container.textContent).toBe(enabled ? "Alpha" : "Disabled"));
|
|
83
|
+
if (!enabled) {
|
|
84
|
+
expect(collection.subscriberCount).toBe(0);
|
|
85
|
+
await act(async () => root!.render(<ClientView active />));
|
|
86
|
+
}
|
|
87
|
+
await act(async () => {
|
|
88
|
+
const transaction = collection.insert({ id: "beta", label: "Beta" });
|
|
89
|
+
await transaction.isPersisted.promise;
|
|
90
|
+
});
|
|
91
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Alpha,Beta"));
|
|
92
|
+
await act(async () => root!.render(<ClientView active selected="beta" />));
|
|
93
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Beta"));
|
|
94
|
+
const { createOptimisticAction } = await import("./optimistic");
|
|
95
|
+
let rejectWrite: (reason: Error) => void = () => {};
|
|
96
|
+
const write = new Promise<void>((_resolve, reject) => {
|
|
97
|
+
rejectWrite = reject;
|
|
98
|
+
});
|
|
99
|
+
const rename = createOptimisticAction({
|
|
100
|
+
onMutate: () =>
|
|
101
|
+
collection.update("beta", (row) => {
|
|
102
|
+
row.label = "Pending";
|
|
103
|
+
}),
|
|
104
|
+
mutationFn: () => write,
|
|
105
|
+
});
|
|
106
|
+
let transaction: ReturnType<typeof rename>;
|
|
107
|
+
await act(async () => {
|
|
108
|
+
transaction = rename(undefined);
|
|
109
|
+
});
|
|
110
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Pending"));
|
|
111
|
+
await act(async () => {
|
|
112
|
+
const refused = expect(transaction!.isPersisted.promise).rejects.toThrow("Write refused");
|
|
113
|
+
rejectWrite(new Error("Write refused"));
|
|
114
|
+
await refused;
|
|
115
|
+
});
|
|
116
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Beta"));
|
|
117
|
+
const lastQuery = observedQuery;
|
|
118
|
+
expect(lastQuery?.subscriberCount).toBeGreaterThan(0);
|
|
119
|
+
await act(async () => root!.unmount());
|
|
120
|
+
root = undefined;
|
|
121
|
+
await vi.waitFor(() => expect(lastQuery?.subscriberCount).toBe(0));
|
|
122
|
+
await vi.waitFor(() => expect(lastQuery?.status).toBe("cleaned-up"));
|
|
123
|
+
} finally {
|
|
124
|
+
await act(async () => root?.unmount());
|
|
125
|
+
container.remove();
|
|
126
|
+
await collection.cleanup();
|
|
127
|
+
await serverCollection.cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
package/src/useLiveQuery.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { useLiveQuery as upstreamUseLiveQuery } from "@tanstack/react-db";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { useLiveQuery } from "./useLiveQuery";
|
|
6
6
|
|
|
@@ -22,9 +22,8 @@ describe("useLiveQuery carries no import.meta (MPO-204)", () => {
|
|
|
22
22
|
expect(source).not.toMatch(/import\.meta/);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it("
|
|
26
|
-
expect(
|
|
27
|
-
expect(source).toMatch(/const IS_SSR: boolean = isServer;/);
|
|
25
|
+
it("preserves the upstream hook and its server snapshot lifecycle", () => {
|
|
26
|
+
expect(useLiveQuery).toBe(upstreamUseLiveQuery);
|
|
28
27
|
});
|
|
29
28
|
|
|
30
29
|
it("has no platform twin — a .web twin replaced the bake once already (MPO-192)", () => {
|
|
@@ -34,10 +33,3 @@ describe("useLiveQuery carries no import.meta (MPO-204)", () => {
|
|
|
34
33
|
}
|
|
35
34
|
});
|
|
36
35
|
});
|
|
37
|
-
|
|
38
|
-
describe("useLiveQuery answers client in a browser-like environment", () => {
|
|
39
|
-
it("resolves isServer false, so the hook delegates to @tanstack/react-db", () => {
|
|
40
|
-
expect(isServer).toBe(false);
|
|
41
|
-
expect(typeof useLiveQuery).toBe("function");
|
|
42
|
-
});
|
|
43
|
-
});
|
package/src/useLiveQuery.ts
CHANGED
|
@@ -1,59 +1,5 @@
|
|
|
1
|
-
import { isServer } from "@multiplatform.one/platform";
|
|
2
|
-
import { useLiveQuery as useLiveQueryBase } from "@tanstack/react-db";
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* state so SSR markup matches the client's first paint.
|
|
2
|
+
* React DB supplies the server snapshot for both SSR and browser hydration.
|
|
3
|
+
* Keep the upstream hook lifecycle intact on every platform.
|
|
8
4
|
*/
|
|
9
|
-
|
|
10
|
-
state: undefined,
|
|
11
|
-
data: undefined,
|
|
12
|
-
collection: undefined,
|
|
13
|
-
status: "loading",
|
|
14
|
-
isLoading: true,
|
|
15
|
-
isReady: false,
|
|
16
|
-
isIdle: false,
|
|
17
|
-
isError: false,
|
|
18
|
-
isCleanedUp: false,
|
|
19
|
-
isEnabled: true,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* True when this module is evaluated during web server rendering.
|
|
24
|
-
*
|
|
25
|
-
* This used to read `import.meta.env.SSR` behind a try/catch. MPO-192 ruled
|
|
26
|
-
* that shape out: two bundles execute mpo as a CLASSIC script — the
|
|
27
|
-
* vxrn/rolldown native bundle for Hermes, and any Metro bundle for
|
|
28
|
-
* platform=web, which is what an Expo DOM component (`'use dom'`) is — and in
|
|
29
|
-
* a classic script `import.meta` is a PARSE-time SyntaxError, so the catch
|
|
30
|
-
* never gets to run. The whole module fails to parse and takes its consumers
|
|
31
|
-
* with it.
|
|
32
|
-
*
|
|
33
|
-
* `isServer` is the repo's own answer and carries no `import.meta`: it comes
|
|
34
|
-
* from @tamagui/constants through @multiplatform.one/platform, and it is
|
|
35
|
-
* split at the FILE level (`constants.native.ts` hardcodes `isServer = false`)
|
|
36
|
-
* rather than by a bundler define. Web server render is the only case that
|
|
37
|
-
* answers true — a browser has navigator + location, native takes the
|
|
38
|
-
* `.native` flavour, and a DOM component's WebView is a browser.
|
|
39
|
-
*/
|
|
40
|
-
const IS_SSR: boolean = isServer;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* SSR-safe wrapper around `useLiveQuery` from `@tanstack/react-db`.
|
|
44
|
-
*
|
|
45
|
-
* The upstream hook calls `useSyncExternalStore(subscribe, getSnapshot)`
|
|
46
|
-
* without a `getServerSnapshot`, so react-dom/server throws "Missing
|
|
47
|
-
* getServerSnapshot, which is required for server-rendered content" the
|
|
48
|
-
* moment any component using it is server rendered (e.g. deep-linking a
|
|
49
|
-
* web route). On the server we skip the subscription entirely and return
|
|
50
|
-
* a static "loading" result; the live query takes over on the client.
|
|
51
|
-
*
|
|
52
|
-
* `IS_SSR` is constant for the lifetime of the process, so hook order
|
|
53
|
-
* never varies within a renderer.
|
|
54
|
-
*/
|
|
55
|
-
export const useLiveQuery: typeof useLiveQueryBase = ((...args: unknown[]) => {
|
|
56
|
-
if (IS_SSR) return SSR_LIVE_QUERY_RESULT;
|
|
57
|
-
// oxlint-disable-next-line rules-of-hooks -- branch is process-constant
|
|
58
|
-
return (useLiveQueryBase as (...args: unknown[]) => unknown)(...args);
|
|
59
|
-
}) as typeof useLiveQueryBase;
|
|
5
|
+
export { useLiveQuery } from "@tanstack/react-db";
|