@multiplatform.one/frappe-ui 7.8.0 → 7.11.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 (84) hide show
  1. package/dist/cjs/FormSidebar.cjs +7 -7
  2. package/dist/cjs/FormSidebar.native.js +7 -7
  3. package/dist/cjs/FormSidebar.native.js.map +1 -1
  4. package/dist/cjs/FrappeChartCard.cjs +29 -3
  5. package/dist/cjs/FrappeChartCard.native.js +29 -3
  6. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  7. package/dist/cjs/FrappeKPICard.cjs +6 -5
  8. package/dist/cjs/FrappeKPICard.native.js +6 -5
  9. package/dist/cjs/FrappeKPICard.native.js.map +1 -1
  10. package/dist/cjs/FrappeListView.cjs +6 -6
  11. package/dist/cjs/FrappeListView.native.js +6 -6
  12. package/dist/cjs/FrappeListView.native.js.map +1 -1
  13. package/dist/cjs/fields/DateRange/index.cjs +30 -2
  14. package/dist/cjs/fields/DateRange/index.native.js +34 -2
  15. package/dist/cjs/fields/DateRange/index.native.js.map +1 -1
  16. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  17. package/dist/esm/FormSidebar.mjs +7 -7
  18. package/dist/esm/FormSidebar.mjs.map +1 -1
  19. package/dist/esm/FormSidebar.native.js +7 -7
  20. package/dist/esm/FormSidebar.native.js.map +1 -1
  21. package/dist/esm/FrappeChartCard.mjs +30 -4
  22. package/dist/esm/FrappeChartCard.mjs.map +1 -1
  23. package/dist/esm/FrappeChartCard.native.js +30 -4
  24. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  25. package/dist/esm/FrappeKPICard.mjs +6 -5
  26. package/dist/esm/FrappeKPICard.mjs.map +1 -1
  27. package/dist/esm/FrappeKPICard.native.js +6 -5
  28. package/dist/esm/FrappeKPICard.native.js.map +1 -1
  29. package/dist/esm/FrappeListView.mjs +6 -6
  30. package/dist/esm/FrappeListView.mjs.map +1 -1
  31. package/dist/esm/FrappeListView.native.js +6 -6
  32. package/dist/esm/FrappeListView.native.js.map +1 -1
  33. package/dist/esm/fields/DateRange/index.mjs +30 -2
  34. package/dist/esm/fields/DateRange/index.mjs.map +1 -1
  35. package/dist/esm/fields/DateRange/index.native.js +34 -2
  36. package/dist/esm/fields/DateRange/index.native.js.map +1 -1
  37. package/dist/esm/shared/devWarn.mjs.map +1 -1
  38. package/dist/esm/shared/devWarn.native.js.map +1 -1
  39. package/dist/jsx/FormSidebar.mjs +7 -7
  40. package/dist/jsx/FormSidebar.mjs.map +1 -1
  41. package/dist/jsx/FormSidebar.native.js +7 -7
  42. package/dist/jsx/FormSidebar.native.js.map +1 -1
  43. package/dist/jsx/FrappeChartCard.mjs +30 -4
  44. package/dist/jsx/FrappeChartCard.mjs.map +1 -1
  45. package/dist/jsx/FrappeChartCard.native.js +29 -3
  46. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  47. package/dist/jsx/FrappeKPICard.mjs +6 -5
  48. package/dist/jsx/FrappeKPICard.mjs.map +1 -1
  49. package/dist/jsx/FrappeKPICard.native.js +6 -5
  50. package/dist/jsx/FrappeKPICard.native.js.map +1 -1
  51. package/dist/jsx/FrappeListView.mjs +6 -6
  52. package/dist/jsx/FrappeListView.mjs.map +1 -1
  53. package/dist/jsx/FrappeListView.native.js +6 -6
  54. package/dist/jsx/FrappeListView.native.js.map +1 -1
  55. package/dist/jsx/fields/DateRange/index.mjs +30 -2
  56. package/dist/jsx/fields/DateRange/index.mjs.map +1 -1
  57. package/dist/jsx/fields/DateRange/index.native.js +34 -2
  58. package/dist/jsx/fields/DateRange/index.native.js.map +1 -1
  59. package/dist/jsx/shared/devWarn.mjs.map +1 -1
  60. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  61. package/package.json +18 -18
  62. package/src/FormSidebar.tsx +7 -7
  63. package/src/FrappeChartCard.spec.tsx +179 -0
  64. package/src/FrappeChartCard.tsx +55 -6
  65. package/src/FrappeKPICard.spec.tsx +25 -0
  66. package/src/FrappeKPICard.tsx +17 -11
  67. package/src/FrappeListView.tsx +6 -6
  68. package/src/FrappeProvider.catchUp.spec.tsx +502 -0
  69. package/src/FrappeProvider.tsx +2 -2
  70. package/src/fields/Date/Date.spec.tsx +8 -1
  71. package/src/fields/Date/nativePresentation.spec.tsx +8 -1
  72. package/src/fields/Date/typeParity.spec.tsx +8 -1
  73. package/src/fields/DateRange/DateRange.spec.tsx +87 -15
  74. package/src/fields/DateRange/index.tsx +47 -3
  75. package/src/shared/devWarn.ts +3 -1
  76. package/types/FrappeChartCard.d.ts +17 -1
  77. package/types/FrappeChartCard.d.ts.map +1 -1
  78. package/types/FrappeKPICard.d.ts +11 -6
  79. package/types/FrappeKPICard.d.ts.map +1 -1
  80. package/types/FrappeProvider.d.ts +2 -2
  81. package/types/FrappeProvider.d.ts.map +1 -1
  82. package/types/fields/DateRange/index.d.ts.map +1 -1
  83. package/types/shared/devWarn.d.ts +1 -1
  84. package/types/shared/devWarn.d.ts.map +1 -1
@@ -0,0 +1,502 @@
1
+ import React, { useEffect } from "react";
2
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
4
+ import {
5
+ getFrappeSyncModule,
6
+ useFrappeCatchUp,
7
+ useFrappeCollection,
8
+ useFrappeInfiniteList,
9
+ useLiveQuery,
10
+ type SyncModule,
11
+ type UseFrappeCollectionConfig,
12
+ } from "@multiplatform.one/frappe";
13
+ import { FrappeProvider, useFrappeConfig } from "./FrappeProvider";
14
+ import { BackfillScope } from "../../frappe/src/sync/types";
15
+
16
+ const { sockets } = vi.hoisted(() => ({
17
+ sockets: [] as Array<{
18
+ connected: boolean;
19
+ deliver: (event: string, payload?: unknown) => void;
20
+ }>,
21
+ }));
22
+ vi.mock("socket.io-client", () => ({
23
+ io: () => {
24
+ const listeners = new Map<string, Set<(payload?: unknown) => void>>();
25
+ const socket = {
26
+ id: "synthetic",
27
+ connected: false,
28
+ auth: {},
29
+ io: { on: vi.fn(), opts: {} },
30
+ on(event: string, handler: (payload?: unknown) => void) {
31
+ if (!listeners.has(event)) listeners.set(event, new Set());
32
+ listeners.get(event)!.add(handler);
33
+ return socket;
34
+ },
35
+ off(event: string, handler: (payload?: unknown) => void) {
36
+ listeners.get(event)?.delete(handler);
37
+ return socket;
38
+ },
39
+ emit: vi.fn(),
40
+ connect() {
41
+ socket.connected = true;
42
+ socket.deliver("connect");
43
+ return socket;
44
+ },
45
+ disconnect() {
46
+ socket.connected = false;
47
+ return socket;
48
+ },
49
+ removeAllListeners() {
50
+ listeners.clear();
51
+ return socket;
52
+ },
53
+ deliver(event: string, payload?: unknown) {
54
+ if (event === "connect") socket.connected = true;
55
+ if (event === "disconnect") socket.connected = false;
56
+ for (const listener of listeners.get(event) ?? []) listener(payload);
57
+ },
58
+ };
59
+ sockets.push(socket);
60
+ return socket;
61
+ },
62
+ }));
63
+
64
+ interface Task {
65
+ name: string;
66
+ doctype: string;
67
+ title: string;
68
+ modified: string;
69
+ }
70
+ const row = (name: string, title = name): Task => ({
71
+ name,
72
+ title,
73
+ doctype: "Task",
74
+ modified: "2026-09-09T00:00:00Z",
75
+ });
76
+ const modules = new Set<SyncModule>();
77
+ const observed = new Map<string, { sync: SyncModule; collection: unknown }>();
78
+ let sequence = 0;
79
+ function connection(): UseFrappeCollectionConfig {
80
+ return { baseURL: `https://public-catch-up-${++sequence}.test`, realtime: false };
81
+ }
82
+ function Probe({ label = "main" }: { label?: string }) {
83
+ const config = useFrappeConfig();
84
+ const sync = getFrappeSyncModule(config)!;
85
+ modules.add(sync);
86
+ const collection = useFrappeCollection<Task>(config, "Task", { filters: [["title", "!=", ""]] });
87
+ const query = useLiveQuery(
88
+ (q) => (collection ? q.from({ task: collection }) : null),
89
+ [collection],
90
+ );
91
+ const state = useFrappeCatchUp(config);
92
+ useEffect(() => {
93
+ observed.set(label, { sync, collection });
94
+ }, [label, sync, collection]);
95
+ return (
96
+ <section>
97
+ <output data-testid={`${label}-rows`}>
98
+ {query.data
99
+ ?.map((task) => `${task.name}:${task.title}`)
100
+ .sort()
101
+ .join(",")}
102
+ </output>
103
+ <output data-testid={`${label}-state`}>
104
+ {state.refreshing ? "refreshing" : "idle"}/{state.stale ? "stale" : "current"}
105
+ </output>
106
+ {state.error ? (
107
+ <p role="alert">
108
+ {state.error instanceof Error ? state.error.message : String(state.error)}
109
+ </p>
110
+ ) : null}
111
+ <button
112
+ onClick={() => {
113
+ void state.retry();
114
+ }}
115
+ >
116
+ Retry {label}
117
+ </button>
118
+ </section>
119
+ );
120
+ }
121
+ function InfiniteProbe() {
122
+ const config = useFrappeConfig();
123
+ const sync = getFrappeSyncModule(config)!;
124
+ modules.add(sync);
125
+ const list = useFrappeInfiniteList<Task>({ config, doctype: "Task" });
126
+ return (
127
+ <output data-testid="infinite">
128
+ {list.error?.message ?? list.items.map((item) => item.name).join(",")}
129
+ </output>
130
+ );
131
+ }
132
+ function server() {
133
+ let rows = [row("a"), row("b")];
134
+ let status = 200;
135
+ let cdc = {
136
+ rows: [] as Task[],
137
+ deleted: [] as { name: string }[],
138
+ checkpoint: "next",
139
+ has_more: false,
140
+ };
141
+ const urls: URL[] = [];
142
+ let gate: Promise<Response> | undefined;
143
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
144
+ const url = new URL(
145
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
146
+ );
147
+ urls.push(url);
148
+ if (gate) {
149
+ const pending = gate;
150
+ gate = undefined;
151
+ return pending;
152
+ }
153
+ if (status !== 200) return Response.json({ message: "Service unavailable" }, { status });
154
+ if (url.pathname === "/api/resource/Task") return Response.json({ data: rows });
155
+ if (url.pathname.startsWith("/api/resource/Task/")) {
156
+ const doc = rows.find((item) => item.name === url.pathname.split("/").at(-1));
157
+ return Response.json({ data: doc }, { status: doc ? 200 : 404 });
158
+ }
159
+ if (url.pathname === "/api/method/live.live.api.backfill")
160
+ return Response.json({ message: cdc });
161
+ return Response.json({ message: "Unsupported method" }, { status: 404 });
162
+ });
163
+ vi.stubGlobal("fetch", fetch);
164
+ return {
165
+ fetch,
166
+ urls,
167
+ setRows: (value: Task[]) => {
168
+ rows = value;
169
+ },
170
+ setStatus: (value: number) => {
171
+ status = value;
172
+ },
173
+ setCdc: (value: typeof cdc) => {
174
+ cdc = value;
175
+ },
176
+ hold: () => {
177
+ let resolve!: (value: Response) => void;
178
+ gate = new Promise<Response>((done) => {
179
+ resolve = done;
180
+ });
181
+ return resolve;
182
+ },
183
+ resources: () => urls.filter((url) => url.pathname === "/api/resource/Task"),
184
+ methods: () => urls.filter((url) => url.pathname.startsWith("/api/method/")),
185
+ };
186
+ }
187
+ async function expectRows(value: string, label = "main") {
188
+ await waitFor(() => expect(screen.getByTestId(`${label}-rows`).textContent).toBe(value));
189
+ }
190
+ async function expectIdle(label = "main") {
191
+ await waitFor(() =>
192
+ expect(screen.getByTestId(`${label}-state`).textContent).toBe("idle/current"),
193
+ );
194
+ }
195
+ beforeEach(() => {
196
+ sockets.length = 0;
197
+ observed.clear();
198
+ });
199
+ afterEach(async () => {
200
+ cleanup();
201
+ for (const sync of modules) await sync.dispose();
202
+ modules.clear();
203
+ vi.unstubAllGlobals();
204
+ vi.useRealTimers();
205
+ });
206
+
207
+ it("refreshes resource-only mounted collections for focus and online without CDC/cursor requests", async () => {
208
+ const api = server();
209
+ const config = {
210
+ ...connection(),
211
+ syncOptions: { catchUpStrategy: "snapshot" as const, cdcInterval: 60000 },
212
+ };
213
+ render(
214
+ <FrappeProvider {...config}>
215
+ <Probe />
216
+ </FrappeProvider>,
217
+ );
218
+ await expectRows("a:a,b:b");
219
+ const initial = observed.get("main")!;
220
+ api.setRows([row("a", "edited"), row("c")]);
221
+ fireEvent(window, new Event("focus"));
222
+ await expectRows("a:edited,c:c");
223
+ await expectIdle();
224
+ api.setRows([]);
225
+ fireEvent(window, new Event("online"));
226
+ await expectRows("");
227
+ await expectIdle();
228
+ expect(observed.get("main")!.collection).toBe(initial.collection);
229
+ expect(api.methods()).toEqual([]);
230
+ expect(api.resources()).toHaveLength(3);
231
+ });
232
+
233
+ it("dispatches the enabled interval through the public snapshot provider", async () => {
234
+ vi.useFakeTimers();
235
+ const api = server();
236
+ render(
237
+ <FrappeProvider
238
+ {...connection()}
239
+ syncOptions={{ catchUpStrategy: "snapshot", cdcInterval: 1000 }}
240
+ >
241
+ <Probe />
242
+ </FrappeProvider>,
243
+ );
244
+ await act(async () => {
245
+ await vi.advanceTimersByTimeAsync(50);
246
+ });
247
+ expect(screen.getByTestId("main-rows").textContent).toBe("a:a,b:b");
248
+ api.setRows([row("timer")]);
249
+ await act(async () => {
250
+ await vi.advanceTimersByTimeAsync(1000);
251
+ });
252
+ expect(screen.getByTestId("main-rows").textContent).toBe("timer:timer");
253
+ expect(api.resources()).toHaveLength(2);
254
+ expect(api.methods()).toEqual([]);
255
+ });
256
+
257
+ it("retains rows through a real HTTP 503, renders qualification, and retries without remounting", async () => {
258
+ const api = server();
259
+ const config = { ...connection(), syncOptions: { catchUpStrategy: "snapshot" as const } };
260
+ const view = render(
261
+ <FrappeProvider {...config}>
262
+ <Probe />
263
+ </FrappeProvider>,
264
+ );
265
+ await expectRows("a:a,b:b");
266
+ const initial = observed.get("main")!.collection;
267
+ const release = api.hold();
268
+ api.setStatus(503);
269
+ fireEvent(window, new Event("focus"));
270
+ await waitFor(() => expect(api.resources()).toHaveLength(2));
271
+ expect(screen.getByTestId("main-state").textContent).toBe("refreshing/current");
272
+ expect(screen.getByTestId("main-rows").textContent).toBe("a:a,b:b");
273
+ release(Response.json({ message: "Service unavailable" }, { status: 503 }));
274
+ await waitFor(() => expect(screen.getByTestId("main-state").textContent).toBe("idle/stale"), {
275
+ timeout: 4000,
276
+ });
277
+ expect(screen.getByRole("alert").textContent).toContain("Showing previous data");
278
+ expect(api.resources()).toHaveLength(5);
279
+ view.rerender(
280
+ <FrappeProvider {...config}>
281
+ <Probe />
282
+ </FrappeProvider>,
283
+ );
284
+ expect(api.resources()).toHaveLength(5);
285
+ api.setStatus(200);
286
+ api.setRows([row("recovered")]);
287
+ const retryRelease = api.hold();
288
+ fireEvent.click(screen.getByRole("button", { name: "Retry main" }));
289
+ await waitFor(() => expect(api.resources()).toHaveLength(6));
290
+ expect(screen.getByTestId("main-state").textContent).toBe("refreshing/stale");
291
+ retryRelease(Response.json({ data: [row("recovered")] }));
292
+ await expectRows("recovered:recovered");
293
+ await expectIdle();
294
+ expect(screen.queryByRole("alert")).toBeNull();
295
+ expect(observed.get("main")!.collection).toBe(initial);
296
+ expect(api.methods()).toEqual([]);
297
+ });
298
+
299
+ it.each(["snapshot", "cdc"] as const)(
300
+ "routes real socket reconnect with %s strategy and preserves realtime delivery",
301
+ async (strategy) => {
302
+ const api = server();
303
+ render(
304
+ <FrappeProvider {...connection()} realtime syncOptions={{ catchUpStrategy: strategy }}>
305
+ <Probe />
306
+ </FrappeProvider>,
307
+ );
308
+ await expectRows("a:a,b:b");
309
+ await waitFor(() => expect(sockets).toHaveLength(1));
310
+ await expectIdle();
311
+ const initialCount = api.urls.length;
312
+ api.setRows([row("socket")]);
313
+ api.setCdc({
314
+ rows: [row("socket")],
315
+ deleted: [{ name: "a" }, { name: "b" }],
316
+ checkpoint: "socket-checkpoint",
317
+ has_more: false,
318
+ });
319
+ act(() => {
320
+ sockets[0].deliver("disconnect", "transport close");
321
+ sockets[0].deliver("connect");
322
+ });
323
+ await expectRows("socket:socket");
324
+ await expectIdle();
325
+ const catchup = api.urls.slice(initialCount);
326
+ expect(
327
+ catchup.some(
328
+ (url) =>
329
+ url.pathname ===
330
+ (strategy === "snapshot" ? "/api/resource/Task" : "/api/method/live.live.api.backfill"),
331
+ ),
332
+ ).toBe(true);
333
+ if (strategy === "snapshot") expect(api.methods()).toEqual([]);
334
+ else {
335
+ expect(observed.get("main")!.sync.__debug().subscriptions[0].checkpoint).toBe(
336
+ "socket-checkpoint",
337
+ );
338
+ fireEvent(window, new Event("online"));
339
+ await waitFor(() =>
340
+ expect(api.methods().at(-1)?.searchParams.get("modified_after")).toBe("socket-checkpoint"),
341
+ );
342
+ await expectIdle();
343
+ }
344
+ api.setRows([row("socket", "realtime edit")]);
345
+ act(() => {
346
+ sockets[0].deliver("doc_update", { doctype: "Task", name: "socket" });
347
+ });
348
+ await expectRows("socket:realtime edit");
349
+ },
350
+ );
351
+
352
+ it("uses CDC when omitted, observes background rejection, and keeps explicit backfill rejecting", async () => {
353
+ const api = server();
354
+ render(
355
+ <FrappeProvider {...connection()}>
356
+ <Probe />
357
+ </FrappeProvider>,
358
+ );
359
+ await expectRows("a:a,b:b");
360
+ api.setStatus(404);
361
+ fireEvent(window, new Event("focus"));
362
+ await waitFor(() => expect(screen.getByTestId("main-state").textContent).toBe("idle/stale"));
363
+ const sync = observed.get("main")!.sync;
364
+ await expect(sync.backfill({ scope: BackfillScope.All })).rejects.toMatchObject({ status: 404 });
365
+ expect(api.methods()[0].searchParams.get("modified_after")).toBe(row("a").modified);
366
+ api.setStatus(200);
367
+ api.setCdc({
368
+ rows: [row("c")],
369
+ deleted: [{ name: "a" }],
370
+ checkpoint: "recovered-checkpoint",
371
+ has_more: false,
372
+ });
373
+ fireEvent.click(screen.getByRole("button", { name: "Retry main" }));
374
+ await expectRows("b:b,c:c");
375
+ await expectIdle();
376
+ });
377
+
378
+ it("isolates same-origin strategy providers and switches collection and state together", async () => {
379
+ const api = server();
380
+ const config = connection();
381
+ const draw = (strategy: "snapshot" | "cdc") => (
382
+ <>
383
+ <FrappeProvider {...config} syncOptions={{ catchUpStrategy: strategy }}>
384
+ <Probe label="left" />
385
+ </FrappeProvider>
386
+ <FrappeProvider {...config}>
387
+ <Probe label="right" />
388
+ </FrappeProvider>
389
+ </>
390
+ );
391
+ const view = render(draw("snapshot"));
392
+ await expectRows("a:a,b:b", "left");
393
+ await expectRows("a:a,b:b", "right");
394
+ expect(observed.get("left")!.sync).not.toBe(observed.get("right")!.sync);
395
+ expect(observed.get("left")!.collection).not.toBe(observed.get("right")!.collection);
396
+ const left = observed.get("left")!.collection;
397
+ view.rerender(draw("snapshot"));
398
+ expect(observed.get("left")!.collection).toBe(left);
399
+ api.setRows([row("snapshot")]);
400
+ api.setCdc({
401
+ rows: [row("cdc")],
402
+ deleted: [{ name: "a" }, { name: "b" }],
403
+ checkpoint: "separate",
404
+ has_more: false,
405
+ });
406
+ fireEvent(window, new Event("focus"));
407
+ await expectRows("snapshot:snapshot", "left");
408
+ await expectRows("cdc:cdc", "right");
409
+ view.rerender(draw("cdc"));
410
+ await expectRows("cdc:cdc", "left");
411
+ expect(observed.get("left")!.collection).toBe(observed.get("right")!.collection);
412
+ expect(observed.get("left")!.sync).toBe(observed.get("right")!.sync);
413
+ });
414
+
415
+ it("refuses progressive snapshot subscriptions through the public hook, with CDC control", async () => {
416
+ const api = server();
417
+ const config = connection();
418
+ const view = render(
419
+ <FrappeProvider {...config} syncOptions={{ catchUpStrategy: "snapshot" }}>
420
+ <InfiniteProbe />
421
+ </FrappeProvider>,
422
+ );
423
+ await waitFor(() =>
424
+ expect(screen.getByTestId("infinite").textContent).toContain("does not support progressive"),
425
+ );
426
+ expect(api.urls).toEqual([]);
427
+ view.rerender(
428
+ <FrappeProvider {...config}>
429
+ <InfiniteProbe />
430
+ </FrappeProvider>,
431
+ );
432
+ await waitFor(() => expect(screen.getByTestId("infinite").textContent).toBe("a,b"));
433
+ expect(api.resources()).toHaveLength(1);
434
+ });
435
+
436
+ it("keeps explicit CDC backfill rejecting even on a snapshot provider", async () => {
437
+ const api = server();
438
+ render(
439
+ <FrappeProvider {...connection()} syncOptions={{ catchUpStrategy: "snapshot" }}>
440
+ <Probe />
441
+ </FrappeProvider>,
442
+ );
443
+ await expectRows("a:a,b:b");
444
+ expect(api.methods()).toEqual([]);
445
+ api.setStatus(404);
446
+ await expect(
447
+ observed.get("main")!.sync.backfill({ scope: BackfillScope.All }),
448
+ ).rejects.toMatchObject({ status: 404 });
449
+ expect(api.methods()).toHaveLength(1);
450
+ expect(api.methods()[0].pathname).toBe("/api/method/live.live.api.backfill");
451
+ });
452
+
453
+ it("coalesces mounted lifecycle overlap into one trailing read", async () => {
454
+ const api = server();
455
+ render(
456
+ <FrappeProvider {...connection()} syncOptions={{ catchUpStrategy: "snapshot" }}>
457
+ <Probe />
458
+ </FrappeProvider>,
459
+ );
460
+ await expectRows("a:a,b:b");
461
+ const first = api.hold();
462
+ fireEvent(window, new Event("focus"));
463
+ await waitFor(() => expect(api.resources()).toHaveLength(2));
464
+ const trailing = api.hold();
465
+ fireEvent(window, new Event("online"));
466
+ fireEvent.click(screen.getByRole("button", { name: "Retry main" }));
467
+ first(Response.json({ data: [row("earlier")] }));
468
+ await waitFor(() => expect(api.resources()).toHaveLength(3));
469
+ for (let i = 0; i < 10; i++) fireEvent(window, new Event("focus"));
470
+ trailing(Response.json({ data: [row("latest")] }));
471
+ await expectRows("latest:latest");
472
+ await expectIdle();
473
+ expect(api.resources()).toHaveLength(3);
474
+ expect(api.methods()).toEqual([]);
475
+ });
476
+
477
+ it("does not publish an obsolete failure after the mounted provider is reset and unmounted", async () => {
478
+ const api = server();
479
+ const view = render(
480
+ <FrappeProvider {...connection()} syncOptions={{ catchUpStrategy: "snapshot" }}>
481
+ <Probe />
482
+ </FrappeProvider>,
483
+ );
484
+ await expectRows("a:a,b:b");
485
+ const sync = observed.get("main")!.sync;
486
+ const release = api.hold();
487
+ let pending!: ReturnType<SyncModule["catchUp"]>;
488
+ act(() => {
489
+ pending = sync.catchUp();
490
+ });
491
+ await waitFor(() => expect(api.resources()).toHaveLength(2));
492
+ view.unmount();
493
+ sync.resetCache();
494
+ const idle = sync.getCatchUpState();
495
+ const listener = vi.fn();
496
+ const unsubscribe = sync.subscribeStore(listener);
497
+ release(Response.json({ message: "obsolete" }, { status: 404 }));
498
+ await pending;
499
+ expect(sync.getCatchUpState()).toBe(idle);
500
+ expect(listener).not.toHaveBeenCalled();
501
+ unsubscribe();
502
+ });
@@ -49,7 +49,7 @@ export interface FrappeConfig {
49
49
  type?: string;
50
50
  };
51
51
  /** Sync options for the live data engine */
52
- syncOptions?: any;
52
+ syncOptions?: UseFrappeCollectionConfig["syncOptions"];
53
53
  /**
54
54
  * Extra Socket.IO client options for the realtime connection — e.g.
55
55
  * `{ transports: ["polling", "websocket"] }` behind dev proxies that
@@ -93,7 +93,7 @@ export interface FrappeProviderProps {
93
93
  type?: string;
94
94
  };
95
95
  /** Sync options for the live data engine */
96
- syncOptions?: any;
96
+ syncOptions?: UseFrappeCollectionConfig["syncOptions"];
97
97
  /** Extra Socket.IO client options for the realtime connection. */
98
98
  socketOptions?: UseFrappeCollectionConfig["socketOptions"];
99
99
  /**
@@ -10,7 +10,7 @@
10
10
  * a seeded value is not a user edit).
11
11
  */
12
12
 
13
- import { describe, it, expect, vi } from "vitest";
13
+ import { describe, it, expect, vi, beforeAll } from "vitest";
14
14
 
15
15
  // The first field-module import in a cold fork transforms the whole fields
16
16
  // graph (same budget as tests/fieldRenderers.spec.tsx).
@@ -79,6 +79,13 @@ function getDayButton(root: ParentNode, day: number): HTMLButtonElement | undefi
79
79
  });
80
80
  }
81
81
 
82
+ // Warm the graph here, not inside the first `it`: the transform is setup cost
83
+ // (110-155s quiet, past 240s under load), and charging it to an assertion made
84
+ // whichever case ran first time out.
85
+ beforeAll(async () => {
86
+ await loadDateField();
87
+ }, 900_000);
88
+
82
89
  describe("Date wrapper — late-arriving value display", () => {
83
90
  it("a value arriving after mount displays, without onChange", async () => {
84
91
  const DateField = await loadDateField();
@@ -18,7 +18,7 @@
18
18
  * than rendered) and the platform flags.
19
19
  */
20
20
 
21
- import { describe, it, expect, vi, beforeEach } from "vitest";
21
+ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
22
22
 
23
23
  // Same cold-fork budget as the sibling Date.spec.tsx: the first field-module
24
24
  // import transforms the whole fields graph.
@@ -132,6 +132,13 @@ async function press(root: ParentNode, text: string) {
132
132
  const lastPick = () => mockState.picks[mockState.picks.length - 1];
133
133
  const waitForPick = (predicate: () => void) => waitFor(predicate, { timeout: 30_000 });
134
134
 
135
+ // Warm the graph here, not inside the first `it`: the transform is setup cost
136
+ // (110-155s quiet, past 240s under load), and charging it to an assertion made
137
+ // whichever case ran first time out.
138
+ beforeAll(async () => {
139
+ await loadNativeDateField();
140
+ }, 900_000);
141
+
135
142
  beforeEach(() => {
136
143
  // The iOS sheet is a portal, so a previous case's chrome would otherwise
137
144
  // still be in the document when the next one asserts on it.
@@ -17,7 +17,7 @@
17
17
  * rather than rendered) and the platform flags. The web half imports neither.
18
18
  */
19
19
 
20
- import { describe, it, expect, vi, beforeEach } from "vitest";
20
+ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
21
21
 
22
22
  // Same cold-fork budget as the sibling Date specs: the first field-module
23
23
  // import transforms the whole fields graph.
@@ -176,6 +176,13 @@ async function selectPeriod(period: "AM" | "PM") {
176
176
  });
177
177
  }
178
178
 
179
+ // Warm the graph here, not inside the first `it`: the transform is setup cost
180
+ // (110-155s quiet, past 240s under load), and charging it to an assertion made
181
+ // whichever case ran first time out.
182
+ beforeAll(async () => {
183
+ await Promise.all([loadWebDateField(), loadNativeDateField()]);
184
+ }, 900_000);
185
+
179
186
  beforeEach(() => {
180
187
  // Both halves portal their surfaces, so a previous case's chrome would
181
188
  // otherwise still be in the document when the next one queries it.