@multiplatform.one/frappe 7.11.0 → 7.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/auth.native.js.map +1 -1
  3. package/dist/cjs/call.native.js.map +1 -1
  4. package/dist/cjs/collection.native.js.map +1 -1
  5. package/dist/cjs/common/types.native.js.map +1 -1
  6. package/dist/cjs/core/http.native.js.map +1 -1
  7. package/dist/cjs/core/types.native.js.map +1 -1
  8. package/dist/cjs/core/wire.native.js.map +1 -1
  9. package/dist/cjs/db.native.js.map +1 -1
  10. package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  11. package/dist/cjs/devtools/index.native.js.map +1 -1
  12. package/dist/cjs/devtools/plugin.native.js.map +1 -1
  13. package/dist/cjs/devtools/theme.native.js.map +1 -1
  14. package/dist/cjs/doctype.cjs +25 -43
  15. package/dist/cjs/doctype.native.js +27 -43
  16. package/dist/cjs/doctype.native.js.map +1 -1
  17. package/dist/cjs/file.native.js.map +1 -1
  18. package/dist/cjs/fixtureDb.native.js.map +1 -1
  19. package/dist/cjs/fixtures.cjs +3 -0
  20. package/dist/cjs/fixtures.native.js +3 -0
  21. package/dist/cjs/fixtures.native.js.map +1 -1
  22. package/dist/cjs/frappeApp.native.js.map +1 -1
  23. package/dist/cjs/frappeReact.cjs +13 -1
  24. package/dist/cjs/frappeReact.native.js +20 -2
  25. package/dist/cjs/frappeReact.native.js.map +1 -1
  26. package/dist/cjs/index.cjs +6 -0
  27. package/dist/cjs/index.native.js +8 -2
  28. package/dist/cjs/index.native.js.map +1 -1
  29. package/dist/cjs/optimistic.native.js.map +1 -1
  30. package/dist/cjs/pacer.native.js.map +1 -1
  31. package/dist/cjs/plugin/index.native.js.map +1 -1
  32. package/dist/cjs/realtime.native.js.map +1 -1
  33. package/dist/cjs/serverLoaders.cjs +74 -0
  34. package/dist/cjs/serverLoaders.native.js +88 -0
  35. package/dist/cjs/serverLoaders.native.js.map +1 -0
  36. package/dist/cjs/sync/backfill.native.js.map +1 -1
  37. package/dist/cjs/sync/filters.native.js.map +1 -1
  38. package/dist/cjs/sync/index.cjs +8 -0
  39. package/dist/cjs/sync/index.native.js +10 -0
  40. package/dist/cjs/sync/index.native.js.map +1 -1
  41. package/dist/cjs/sync/merge.native.js.map +1 -1
  42. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  43. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  44. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  45. package/dist/cjs/sync/persistence.native.js.map +1 -1
  46. package/dist/cjs/sync/realtime.native.js.map +1 -1
  47. package/dist/cjs/sync/rehydration.cjs +4 -0
  48. package/dist/cjs/sync/rehydration.native.js +6 -0
  49. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  50. package/dist/cjs/sync/storage.native.js.map +1 -1
  51. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  52. package/dist/cjs/sync/types.native.js.map +1 -1
  53. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  54. package/dist/cjs/syncOptionsKey.native.js.map +1 -1
  55. package/dist/cjs/useLiveQuery.cjs +2 -20
  56. package/dist/cjs/useLiveQuery.native.js +1 -22
  57. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  58. package/dist/cjs/util.native.js.map +1 -1
  59. package/dist/esm/doctype.mjs +23 -44
  60. package/dist/esm/doctype.mjs.map +1 -1
  61. package/dist/esm/doctype.native.js +25 -44
  62. package/dist/esm/doctype.native.js.map +1 -1
  63. package/dist/esm/fixtures.mjs +3 -0
  64. package/dist/esm/fixtures.mjs.map +1 -1
  65. package/dist/esm/fixtures.native.js +3 -0
  66. package/dist/esm/fixtures.native.js.map +1 -1
  67. package/dist/esm/frappeReact.mjs +13 -1
  68. package/dist/esm/frappeReact.mjs.map +1 -1
  69. package/dist/esm/frappeReact.native.js +19 -1
  70. package/dist/esm/frappeReact.native.js.map +1 -1
  71. package/dist/esm/index.mjs +3 -2
  72. package/dist/esm/index.mjs.map +1 -1
  73. package/dist/esm/index.native.js +3 -2
  74. package/dist/esm/index.native.js.map +1 -1
  75. package/dist/esm/serverLoaders.mjs +48 -0
  76. package/dist/esm/serverLoaders.mjs.map +1 -0
  77. package/dist/esm/serverLoaders.native.js +59 -0
  78. package/dist/esm/serverLoaders.native.js.map +1 -0
  79. package/dist/esm/sync/index.mjs +8 -0
  80. package/dist/esm/sync/index.mjs.map +1 -1
  81. package/dist/esm/sync/index.native.js +10 -0
  82. package/dist/esm/sync/index.native.js.map +1 -1
  83. package/dist/esm/sync/rehydration.mjs +4 -0
  84. package/dist/esm/sync/rehydration.mjs.map +1 -1
  85. package/dist/esm/sync/rehydration.native.js +6 -0
  86. package/dist/esm/sync/rehydration.native.js.map +1 -1
  87. package/dist/esm/useLiveQuery.mjs +1 -19
  88. package/dist/esm/useLiveQuery.mjs.map +1 -1
  89. package/dist/esm/useLiveQuery.native.js +1 -22
  90. package/dist/esm/useLiveQuery.native.js.map +1 -1
  91. package/dist/jsx/doctype.mjs +23 -44
  92. package/dist/jsx/doctype.mjs.map +1 -1
  93. package/dist/jsx/doctype.native.js +27 -43
  94. package/dist/jsx/doctype.native.js.map +1 -1
  95. package/dist/jsx/fixtures.mjs +3 -0
  96. package/dist/jsx/fixtures.mjs.map +1 -1
  97. package/dist/jsx/fixtures.native.js +3 -0
  98. package/dist/jsx/fixtures.native.js.map +1 -1
  99. package/dist/jsx/frappeReact.mjs +13 -1
  100. package/dist/jsx/frappeReact.mjs.map +1 -1
  101. package/dist/jsx/frappeReact.native.js +19 -1
  102. package/dist/jsx/frappeReact.native.js.map +1 -1
  103. package/dist/jsx/index.js +3 -2
  104. package/dist/jsx/index.js.map +1 -1
  105. package/dist/jsx/index.mjs +3 -2
  106. package/dist/jsx/index.mjs.map +1 -1
  107. package/dist/jsx/index.native.js +6 -0
  108. package/dist/jsx/index.native.js.map +1 -1
  109. package/dist/jsx/serverLoaders.mjs +48 -0
  110. package/dist/jsx/serverLoaders.mjs.map +1 -0
  111. package/dist/jsx/serverLoaders.native.js +88 -0
  112. package/dist/jsx/serverLoaders.native.js.map +1 -0
  113. package/dist/jsx/sync/index.mjs +8 -0
  114. package/dist/jsx/sync/index.mjs.map +1 -1
  115. package/dist/jsx/sync/index.native.js +10 -0
  116. package/dist/jsx/sync/index.native.js.map +1 -1
  117. package/dist/jsx/sync/rehydration.mjs +4 -0
  118. package/dist/jsx/sync/rehydration.mjs.map +1 -1
  119. package/dist/jsx/sync/rehydration.native.js +6 -0
  120. package/dist/jsx/sync/rehydration.native.js.map +1 -1
  121. package/dist/jsx/useLiveQuery.mjs +1 -19
  122. package/dist/jsx/useLiveQuery.mjs.map +1 -1
  123. package/dist/jsx/useLiveQuery.native.js +1 -22
  124. package/dist/jsx/useLiveQuery.native.js.map +1 -1
  125. package/package.json +9 -9
  126. package/src/doctype.spec.ts +148 -5
  127. package/src/doctype.ts +202 -173
  128. package/src/fixtures.ts +1 -0
  129. package/src/frappeReact.hydration.spec.tsx +164 -0
  130. package/src/frappeReact.spec.ts +56 -0
  131. package/src/frappeReact.ts +31 -5
  132. package/src/index.ts +11 -0
  133. package/src/serverLoaders.ts +77 -0
  134. package/src/sync/index.ts +9 -0
  135. package/src/sync/rehydration.ts +5 -0
  136. package/src/sync/syncModule.spec.ts +20 -0
  137. package/src/useLiveQuery.hydration.spec.tsx +129 -0
  138. package/src/useLiveQuery.spec.ts +3 -11
  139. package/src/useLiveQuery.ts +3 -57
  140. package/types/doctype.d.ts +117 -33
  141. package/types/doctype.d.ts.map +1 -1
  142. package/types/fixtures.d.ts.map +1 -1
  143. package/types/frappeReact.d.ts +3 -2
  144. package/types/frappeReact.d.ts.map +1 -1
  145. package/types/index.d.ts +3 -2
  146. package/types/index.d.ts.map +1 -1
  147. package/types/serverLoaders.d.ts +40 -0
  148. package/types/serverLoaders.d.ts.map +1 -0
  149. package/types/sync/index.d.ts +6 -0
  150. package/types/sync/index.d.ts.map +1 -1
  151. package/types/sync/rehydration.d.ts +2 -0
  152. package/types/sync/rehydration.d.ts.map +1 -1
  153. package/types/useLiveQuery.d.ts +3 -13
  154. package/types/useLiveQuery.d.ts.map +1 -1
@@ -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 = {
@@ -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 first active SyncModule, or undefined if none exist.
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 TanStack DB Collection instance, or undefined if config is not provided
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 FIRST render (same contract as before the cache). An empty doctype is
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
- // creates the real collection.
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,
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { isServer } from "@multiplatform.one/platform";
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("takes its SSR answer from the platform package, which is split by FILE and not by a bundler define", () => {
26
- expect(source).toMatch(/import \{ isServer \} from "@multiplatform\.one\/platform";/);
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
- });
@@ -1,59 +1,5 @@
1
- import { isServer } from "@multiplatform.one/platform";
2
- import { useLiveQuery as useLiveQueryBase } from "@tanstack/react-db";
3
-
4
1
  /**
5
- * Result returned while server rendering: mirrors the shape
6
- * `@tanstack/react-db` produces during its initial client-side "loading"
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
- const SSR_LIVE_QUERY_RESULT = Object.freeze({
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";