@pylonsync/sync 0.3.264 → 0.3.266

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.264",
6
+ "version": "0.3.266",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -217,6 +217,51 @@ export class SyncEngine {
217
217
  return this._hydrated;
218
218
  }
219
219
 
220
+ /**
221
+ * True once the engine has a *server-confirmed* initial view: the first
222
+ * pull (snapshot) settled, OR the IndexedDB cache already held rows, OR a
223
+ * fallback deadline elapsed so we never pin forever. This is what
224
+ * `useQuery`'s `loading` waits on — NOT `isHydrated()`. The distinction
225
+ * matters: `isHydrated()` flips true the instant the local cache loads,
226
+ * which on a cold/empty cache (first visit, or right after an org switch
227
+ * wipes the replica) is immediate and EMPTY — so a `loading` gated on it
228
+ * drops to false while the projects/rows are still en route from the
229
+ * server, and the UI flashes its empty state for the ~seconds until the
230
+ * pull lands. Gating on this signal keeps `loading` true through that
231
+ * window so callers can show a skeleton instead.
232
+ */
233
+ private _initialSyncSettled = false;
234
+ isInitialSyncSettled(): boolean {
235
+ return this._initialSyncSettled;
236
+ }
237
+
238
+ private _initialSyncFallback: ReturnType<typeof setTimeout> | null = null;
239
+
240
+ /** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
241
+ * re-reads and drops its loading state. */
242
+ private markInitialSyncSettled(): void {
243
+ if (this._initialSyncFallback !== null) {
244
+ clearTimeout(this._initialSyncFallback);
245
+ this._initialSyncFallback = null;
246
+ }
247
+ if (this._initialSyncSettled) return;
248
+ this._initialSyncSettled = true;
249
+ this.store.notify();
250
+ }
251
+
252
+ /** Safety net so `loading` never pins: settle after a deadline even if no
253
+ * pull lands (offline, or a multi-tab follower of an empty entity that
254
+ * never receives a broadcast). The real pull settles it far sooner in the
255
+ * normal case. Re-armable — a replica wipe (org switch / token flip) resets
256
+ * the signal and re-arms this. */
257
+ private armInitialSyncFallback(): void {
258
+ if (this._initialSyncFallback !== null) clearTimeout(this._initialSyncFallback);
259
+ this._initialSyncFallback = setTimeout(() => {
260
+ this._initialSyncFallback = null;
261
+ this.markInitialSyncSettled();
262
+ }, 12_000);
263
+ }
264
+
220
265
  /**
221
266
  * True when the engine drained at least one row OR a saved cursor
222
267
  * out of IndexedDB during `start()`. Distinguishes a returning user
@@ -522,6 +567,9 @@ export class SyncEngine {
522
567
  this.running = true;
523
568
  this.setConnectionStatus("connecting");
524
569
  warnIfMisconfigured(this.config.baseUrl);
570
+ // Arm the loading-settle safety net before any async work, so a multi-tab
571
+ // follower (which never pulls) or an offline start can't pin loading.
572
+ this.armInitialSyncFallback();
525
573
 
526
574
  // Load persisted data if available.
527
575
  const shouldPersist = this.config.persist !== false && typeof indexedDB !== "undefined";
@@ -575,9 +623,17 @@ export class SyncEngine {
575
623
  // no changes (replica already at cursor), subscribers stay stuck on
576
624
  // their initial empty snapshot until the first WS event arrives.
577
625
  this._hydrated = true;
578
- if (hydrated) this.store.notify();
579
- else this.store.notify(); // notify even on empty cache so useQuery
580
- // sees `isHydrated()` flip and can drop its initial loading state.
626
+ if (hydrated) {
627
+ this.store.notify();
628
+ // Cache already held rows: we have a usable view now, so settle the
629
+ // initial-sync signal immediately — a returning user sees data on
630
+ // frame one; the pull below just refreshes it. (An EMPTY cache does
631
+ // NOT settle here — loading stays true until the pull confirms,
632
+ // which is the whole point: no empty-state flash before first sync.)
633
+ this.markInitialSyncSettled();
634
+ } else {
635
+ this.store.notify();
636
+ }
581
637
 
582
638
  // Apply the cached cursor BEFORE pull so the first pull is a
583
639
  // delta against where we left off, not a full re-snapshot.
@@ -705,7 +761,9 @@ export class SyncEngine {
705
761
  void this.push();
706
762
  }
707
763
 
708
- // Pull from server, then connect real-time transport.
764
+ // Pull from server, then connect real-time transport. `pull()` settles the
765
+ // initial-sync signal on completion (leader path), so useQuery's loading
766
+ // drops here on the normal fast path and the start() fallback is cancelled.
709
767
  await this.pull();
710
768
 
711
769
  // Save cursor after pull. Fire-and-forget on bootstrap — the
@@ -1260,6 +1318,13 @@ export class SyncEngine {
1260
1318
  const wipeMutations = opts.wipeMutations === true;
1261
1319
  this.cursor = { last_seq: 0 };
1262
1320
  this.store.clearAll();
1321
+ // The replica was just wiped and will re-pull from 0 (org switch, identity
1322
+ // flip, or a 410 cursor reset). Re-enter "loading" until that re-pull lands
1323
+ // rows — otherwise switching to another org flashes an empty list for the
1324
+ // seconds the snapshot takes. Re-arm the fallback so it can't pin; the
1325
+ // arriving rows (populated org) or the deadline (empty org) re-settle it.
1326
+ this._initialSyncSettled = false;
1327
+ this.armInitialSyncFallback();
1263
1328
  // Disk is about to be wiped + re-pulled from 0, so any prior
1264
1329
  // persist degradation is moot — start the durability invariant
1265
1330
  // fresh. (If the fresh snapshot also fails to persist, enqueueApply
@@ -1386,7 +1451,13 @@ export class SyncEngine {
1386
1451
  * recurses into `pullInner` directly to avoid self-deadlock on the
1387
1452
  * queue. */
1388
1453
  async pull(): Promise<void> {
1389
- return this.opQueue.enqueue("pull", () => this.pullInner());
1454
+ await this.opQueue.enqueue("pull", () => this.pullInner());
1455
+ // A completed leader pull is a server-confirmed view — settle the
1456
+ // initial-sync signal so `useQuery`'s loading drops (cold start, and the
1457
+ // re-sync after a replica wipe / org switch). Followers no-op in
1458
+ // pullInner and settle via the leader's broadcasts or the fallback, so we
1459
+ // gate on leadership here to avoid an empty-state flash on a follower.
1460
+ if (this.isMultiTabLeader) this.markInitialSyncSettled();
1390
1461
  }
1391
1462
 
1392
1463
  private async pullInner(): Promise<void> {
@@ -0,0 +1,77 @@
1
+ // Pins the "initial-sync settled" signal that `useQuery`'s `loading` gates on.
2
+ //
3
+ // The bug it guards: `loading` used to drop on `isHydrated()`, which flips true
4
+ // the instant the local IndexedDB cache loads — immediate and EMPTY on a cold
5
+ // cache (first visit) or right after an org switch wipes the replica. So a UI
6
+ // dropped out of "loading" while the rows were still en route from the server,
7
+ // flashing its empty state for the seconds until the snapshot landed. The fix
8
+ // adds `isInitialSyncSettled()`, true only after the first SERVER pull settles
9
+ // (or the cache already had rows, or a fallback deadline) — so callers can hold
10
+ // a skeleton through that window.
11
+ //
12
+ // Hardening: each assertion fails if the signal reverts to settling on local
13
+ // hydration (the broken behavior). Not decorative.
14
+
15
+ import { afterEach, describe, expect, test } from "bun:test";
16
+
17
+ import { createTestEnv, type TestEnv } from "./test-harness";
18
+
19
+ describe("initial-sync loading signal", () => {
20
+ let env: TestEnv | null = null;
21
+
22
+ afterEach(async () => {
23
+ if (env) {
24
+ await env.dispose();
25
+ env = null;
26
+ }
27
+ });
28
+
29
+ test("stays pending until the first server pull settles (no empty-state flash on cold load)", async () => {
30
+ let settledMidPull: boolean | null = null;
31
+ env = createTestEnv({
32
+ beforePull: (_auth, since) => {
33
+ // Captured the instant the server is about to answer the FIRST
34
+ // snapshot pull (since === 0). The engine has already finished local
35
+ // hydration here (so `isHydrated()` is true), but it must NOT be
36
+ // "initial-sync settled" yet — that's what keeps `loading` true.
37
+ if (since === 0 && settledMidPull === null) {
38
+ settledMidPull = env!.engine.isInitialSyncSettled();
39
+ }
40
+ },
41
+ });
42
+ env.signIn({ userId: "u1" });
43
+
44
+ // Before start(): definitely not settled.
45
+ expect(env.engine.isInitialSyncSettled()).toBe(false);
46
+
47
+ await env.start();
48
+
49
+ // The crux: mid-pull the signal was still false (a UI would show a
50
+ // skeleton, not its empty state). If `loading` reverts to gating on
51
+ // `isHydrated()`, this captured value flips to true and the test fails.
52
+ expect(settledMidPull).toBe(false);
53
+
54
+ // Once the pull confirms (here: an empty result), it settles — so an empty
55
+ // list now reads as a real "no rows", not "still fetching".
56
+ expect(env.engine.isInitialSyncSettled()).toBe(true);
57
+ });
58
+
59
+ test("a replica wipe (org switch / token flip) re-enters the loading state", async () => {
60
+ env = createTestEnv();
61
+ env.signIn({ userId: "u1" });
62
+ await env.start();
63
+ expect(env.engine.isInitialSyncSettled()).toBe(true);
64
+
65
+ // The org-switch path wipes the replica via resetReplicaInner. The signal
66
+ // must reset so the UI re-shows a skeleton during the re-sync instead of
67
+ // flashing the previous org's (or an empty) list.
68
+ await env.engine.resetReplica();
69
+ expect(env.engine.isInitialSyncSettled()).toBe(false);
70
+
71
+ // And it re-settles once the next pull lands.
72
+ await env.flush();
73
+ await env.engine.pull();
74
+ await env.flush();
75
+ expect(env.engine.isInitialSyncSettled()).toBe(true);
76
+ });
77
+ });