@pylonsync/sync 0.3.265 → 0.3.267
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 +1 -1
- package/src/index.ts +76 -5
- package/src/initial-sync-loading.test.ts +77 -0
package/package.json
CHANGED
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)
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
+
});
|