@pylonsync/react 0.22.47 → 0.22.58

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.
@@ -0,0 +1,45 @@
1
+ // The local replica answers a filter the way the server does. The cases in
2
+ // query-filter-cases.json also run against the server
3
+ // (crates/runtime/tests/query_filter_parity.rs), so both sides are held to
4
+ // the same answers.
5
+
6
+ import { expect, spyOn, test } from "bun:test";
7
+ import cases from "./query-filter-cases.json";
8
+ import { compareForOrder, compileWhere } from "./queryFilter";
9
+
10
+ for (const { where, keys } of cases.cases) {
11
+ test(`where ${JSON.stringify(where)}`, () => {
12
+ const match = compileWhere(where);
13
+ const got = cases.rows.filter((r) => match(r)).map((r) => r.key);
14
+ expect(got).toEqual(keys);
15
+ });
16
+ }
17
+
18
+ test("an operator that cannot apply matches nothing and warns once in dev", () => {
19
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
20
+ const match = compileWhere({ rank: { $gt: "2" } });
21
+ expect(cases.rows.filter((r) => match(r))).toEqual([]);
22
+ expect(cases.rows.filter((r) => match(r))).toEqual([]);
23
+ expect(warn).toHaveBeenCalledTimes(1);
24
+ expect(String(warn.mock.calls[0]?.[0])).toContain("where.rank.$gt");
25
+
26
+ const unknown = compileWhere({ rank: { $between: [1, 2] } });
27
+ expect(cases.rows.filter((r) => unknown(r))).toEqual([]);
28
+ expect(warn).toHaveBeenCalledTimes(2);
29
+ warn.mockRestore();
30
+ });
31
+
32
+ test("ordering follows the server: code units, not locale; datetimes by time", () => {
33
+ const titles = ["banana", "Banana", "apple", "cherry"];
34
+ expect([...titles].sort((a, b) => compareForOrder(a, b, "asc"))).toEqual([
35
+ "Banana",
36
+ "apple",
37
+ "banana",
38
+ "cherry",
39
+ ]);
40
+ const times = ["2026-10-01T12:00:00Z", "2026-10-01T08:00:00-05:00"];
41
+ expect([...times].sort((a, b) => compareForOrder(a, b, "asc"))).toEqual([
42
+ "2026-10-01T12:00:00Z",
43
+ "2026-10-01T08:00:00-05:00",
44
+ ]);
45
+ });
@@ -0,0 +1,138 @@
1
+ // Where-filter evaluation for queries answered from the local replica.
2
+ //
3
+ // The server answers the same filter in SQL, so these comparisons follow
4
+ // the server's:
5
+ //
6
+ // - Numbers compare numerically.
7
+ // - Strings compare by code unit, like SQLite's BINARY collation (not by
8
+ // locale).
9
+ // - Two RFC 3339 timestamps compare as instants. The server stores every
10
+ // `datetime` value in one canonical UTC form and canonicalizes a filter
11
+ // operand the same way, so `occurredAt: { $gte: "2026-10-01T00:00:00Z" }`
12
+ // matches by time whatever offset or precision either side was written
13
+ // with.
14
+ // - A missing or null value matches no range (SQL `NULL > x` is not true).
15
+ //
16
+ // An operator that cannot apply (a number against a string, an unknown
17
+ // operator) matches nothing, as on the server, and warns once in dev, so
18
+ // a filter that silently empties a screen shows up in the console.
19
+
20
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
21
+
22
+ /** Epoch milliseconds of an RFC 3339 timestamp, or null. */
23
+ function instant(value: string): number | null {
24
+ if (!RFC3339.test(value)) return null;
25
+ const ms = Date.parse(value);
26
+ return Number.isNaN(ms) ? null : ms;
27
+ }
28
+
29
+ /**
30
+ * Order two values the way the server's SQL does: negative, zero or
31
+ * positive; `null` when they cannot be compared (different types, or an
32
+ * object). Null and undefined are not handled here.
33
+ */
34
+ export function compareValues(a: unknown, b: unknown): number | null {
35
+ if (typeof a === "number" && typeof b === "number") return a - b;
36
+ if (typeof a === "string" && typeof b === "string") {
37
+ const ta = instant(a);
38
+ const tb = ta === null ? null : instant(b);
39
+ if (ta !== null && tb !== null) return ta - tb;
40
+ return a < b ? -1 : a > b ? 1 : 0;
41
+ }
42
+ if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
43
+ return null;
44
+ }
45
+
46
+ const warned = new Set<string>();
47
+
48
+ function warnOnce(field: string, op: string, reason: string): void {
49
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
50
+ const key = `${field}:${op}:${reason}`;
51
+ if (warned.has(key)) return;
52
+ warned.add(key);
53
+ // eslint-disable-next-line no-console
54
+ console.warn(`[pylon] where.${field}.${op} cannot apply (${reason}); it matches no rows.`);
55
+ }
56
+
57
+ type Row = Record<string, unknown>;
58
+
59
+ const RANGE: Record<string, (c: number) => boolean> = {
60
+ $gt: (c) => c > 0,
61
+ $gte: (c) => c >= 0,
62
+ $lt: (c) => c < 0,
63
+ $lte: (c) => c <= 0,
64
+ };
65
+
66
+ /** A predicate for `where` (keys other than `$order` and `$limit`). */
67
+ export function compileWhere(where: Record<string, unknown>): (row: Row) => boolean {
68
+ const fields = Object.entries(where)
69
+ .filter(([key]) => key !== "$order" && key !== "$limit")
70
+ .map(([key, val]) => ({
71
+ key,
72
+ val,
73
+ operators:
74
+ val !== null && typeof val === "object" && !Array.isArray(val)
75
+ ? Object.entries(val as Record<string, unknown>).map(([op, opVal]) => ({
76
+ op,
77
+ opVal,
78
+ members: op === "$in" && Array.isArray(opVal) ? new Set(opVal) : undefined,
79
+ }))
80
+ : undefined,
81
+ }));
82
+ return (row) => {
83
+ for (const { key, val, operators } of fields) {
84
+ const rowVal = row[key];
85
+ if (!operators) {
86
+ if (rowVal !== val) return false;
87
+ continue;
88
+ }
89
+ for (const { op, opVal, members } of operators) {
90
+ const range = RANGE[op];
91
+ if (range) {
92
+ if (rowVal === null || rowVal === undefined) return false;
93
+ if (opVal === null || opVal === undefined) {
94
+ warnOnce(key, op, "the operand is null");
95
+ return false;
96
+ }
97
+ const c = compareValues(rowVal, opVal);
98
+ if (c === null) {
99
+ warnOnce(key, op, `a ${typeof rowVal} value against a ${typeof opVal} operand`);
100
+ return false;
101
+ }
102
+ if (!range(c)) return false;
103
+ continue;
104
+ }
105
+ switch (op) {
106
+ case "$not":
107
+ if (rowVal === opVal) return false;
108
+ break;
109
+ case "$like":
110
+ if (!(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal)))
111
+ return false;
112
+ break;
113
+ case "$in":
114
+ if (!members?.has(rowVal)) return false;
115
+ break;
116
+ default:
117
+ warnOnce(key, op, "unknown operator");
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+ return true;
123
+ };
124
+ }
125
+
126
+ /** Sort comparator for `$order` / `orderBy`: nulls last ascending, the
127
+ * server's value order otherwise. */
128
+ export function compareForOrder(a: unknown, b: unknown, dir: "asc" | "desc"): number {
129
+ const mult = dir === "desc" ? -1 : 1;
130
+ if (a === b) return 0;
131
+ if (a === undefined || a === null) return mult;
132
+ if (b === undefined || b === null) return -mult;
133
+ const c = compareValues(a, b);
134
+ if (c !== null) return Math.sign(c) * mult;
135
+ const sa = String(a);
136
+ const sb = String(b);
137
+ return (sa < sb ? -1 : sa > sb ? 1 : 0) * mult;
138
+ }
@@ -0,0 +1,98 @@
1
+ // useAppUpdate re-renders when a new build appears, and AppUpdateBar shows
2
+ // the notice and runs apply().
3
+
4
+ import { afterEach, expect, test } from "bun:test";
5
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
6
+ import { renderToString } from "react-dom/server";
7
+
8
+ import { AppUpdateBar } from "./AppUpdateBar";
9
+ import { createAppUpdate, type AppUpdateEnv } from "./appUpdate";
10
+ import { useAppUpdate } from "./useAppUpdate";
11
+
12
+ afterEach(cleanup);
13
+
14
+ function env(latest: { version: string; install?: string }, reloads: { n: number }) {
15
+ const e: AppUpdateEnv = {
16
+ serviceWorker: null,
17
+ fetch: async () => ({ ok: true, json: async () => latest }),
18
+ document: null,
19
+ setInterval: () => 0,
20
+ clearInterval: () => {},
21
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
22
+ clearTimeout: (h) => clearTimeout(h as any),
23
+ now: () => Date.now(),
24
+ reload: () => {
25
+ reloads.n++;
26
+ },
27
+ sessionStorage: null,
28
+ localStorage: null,
29
+ iosStandalone: false,
30
+ pendingWrites: () => null,
31
+ askVersion: async () => null,
32
+ warn: () => {},
33
+ };
34
+ return e;
35
+ }
36
+
37
+ function Probe({ updater }: { updater: ReturnType<typeof createAppUpdate> }) {
38
+ const u = useAppUpdate(updater);
39
+ return <span data-testid="state">{`${u.version} -> ${u.latestVersion} ready=${u.updateReady}`}</span>;
40
+ }
41
+
42
+ test("re-renders when the server reports a new build", async () => {
43
+ const latest = { version: "v1" };
44
+ const updater = createAppUpdate({
45
+ build: { version: "v1", builtAt: null },
46
+ production: true,
47
+ env: env(latest, { n: 0 }),
48
+ });
49
+ render(<Probe updater={updater} />);
50
+ await waitFor(() =>
51
+ expect(screen.getByTestId("state").textContent).toBe("v1 -> v1 ready=false"),
52
+ );
53
+ latest.version = "v2";
54
+ await act(async () => {
55
+ await updater.check();
56
+ });
57
+ expect(screen.getByTestId("state").textContent).toBe("v1 -> v2 ready=true");
58
+ });
59
+
60
+ test("renders the empty state on the server", () => {
61
+ const updater = createAppUpdate({
62
+ build: { version: "v1", builtAt: null },
63
+ production: true,
64
+ env: env({ version: "v2" }, { n: 0 }),
65
+ });
66
+ expect(renderToString(<Probe updater={updater} />)).toContain("null -&gt; null ready=false");
67
+ expect(renderToString(<AppUpdateBar updater={updater} />)).toBe("");
68
+ });
69
+
70
+ test("AppUpdateBar shows the notice when a build is ready, and the button applies it", async () => {
71
+ const reloads = { n: 0 };
72
+ const updater = createAppUpdate({
73
+ build: { version: "v1", builtAt: null },
74
+ production: true,
75
+ env: env({ version: "v2" }, reloads),
76
+ });
77
+ render(<AppUpdateBar updater={updater} className="bar" />);
78
+ const button = await screen.findByRole("button", { name: "Update" });
79
+ expect(screen.getByRole("status").getAttribute("data-pylon-app-update")).toBe("ready");
80
+ expect(screen.getByRole("status").className).toBe("bar");
81
+ await act(async () => {
82
+ fireEvent.click(button);
83
+ });
84
+ await waitFor(() => expect(reloads.n).toBe(1));
85
+ });
86
+
87
+ test("AppUpdateBar renders nothing without an update", async () => {
88
+ const updater = createAppUpdate({
89
+ build: { version: "v1", builtAt: null },
90
+ production: true,
91
+ env: env({ version: "v1" }, { n: 0 }),
92
+ });
93
+ const { container } = render(<AppUpdateBar updater={updater} />);
94
+ await act(async () => {
95
+ await updater.check();
96
+ });
97
+ expect(container.innerHTML).toBe("");
98
+ });
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import {
5
+ appUpdate,
6
+ INERT_APP_UPDATE_STATE,
7
+ type AppUpdater,
8
+ type AppUpdateState,
9
+ } from "./appUpdate";
10
+
11
+ export interface UseAppUpdateReturn extends AppUpdateState {
12
+ /** Wait until the outbox has no pending writes, then load the newest
13
+ * build (the page reloads). */
14
+ apply: () => Promise<void>;
15
+ /** Ask the server for the latest build now. The hook also checks when
16
+ * the page becomes visible and every five minutes. */
17
+ check: () => Promise<void>;
18
+ }
19
+
20
+ const serverSnapshot = () => INERT_APP_UPDATE_STATE;
21
+
22
+ /**
23
+ * Whether a newer build of the app is deployed, and a way to load it.
24
+ * Works in apps that set `buildManifest({ offline })`; elsewhere, under
25
+ * `pylon dev` and during SSR, `updateReady` stays false and `apply()` does
26
+ * nothing.
27
+ *
28
+ * ```tsx
29
+ * function UpdateNotice() {
30
+ * const { updateReady, applying, waitingForSync, apply } = useAppUpdate();
31
+ * if (!updateReady) return null;
32
+ * return (
33
+ * <div role="status">
34
+ * A new version of the app is available.
35
+ * <button onClick={apply} disabled={applying}>
36
+ * {waitingForSync ? "Saving changes" : "Update"}
37
+ * </button>
38
+ * </div>
39
+ * );
40
+ * }
41
+ * ```
42
+ *
43
+ * Pass an explicit `updater` (from `createAppUpdate`) to use one other than
44
+ * the page's.
45
+ */
46
+ export function useAppUpdate(updater: AppUpdater = appUpdate): UseAppUpdateReturn {
47
+ const state = useSyncExternalStore(updater.subscribe, updater.getSnapshot, serverSnapshot);
48
+ return { ...state, apply: updater.apply, check: updater.check };
49
+ }
@@ -0,0 +1,82 @@
1
+ // `useOutbox` re-renders as writes go pending, reach the server, or fail, and
2
+ // its retry/dismiss act on the failed list.
3
+
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
6
+ import type { SyncEngine } from "@pylonsync/sync";
7
+ import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
8
+
9
+ import { useOutbox } from "./useOutbox";
10
+
11
+ const asEngine = (e: TestEnv["engine"]): SyncEngine => e as unknown as SyncEngine;
12
+
13
+ let env: TestEnv | null = null;
14
+ afterEach(async () => {
15
+ cleanup();
16
+ if (env) {
17
+ await env.dispose();
18
+ env = null;
19
+ }
20
+ });
21
+
22
+ let latest: ReturnType<typeof useOutbox> | null = null;
23
+ function Probe({ engine }: { engine: SyncEngine }) {
24
+ const outbox = useOutbox(engine);
25
+ latest = outbox;
26
+ return (
27
+ <span data-testid="outbox">
28
+ {`${outbox.pending.length} not synced, ${outbox.failed.length} failed` +
29
+ (outbox.failed[0] ? `: ${outbox.failed[0].error}` : "")}
30
+ </span>
31
+ );
32
+ }
33
+
34
+ const text = () => screen.getByTestId("outbox").textContent;
35
+
36
+ describe("useOutbox", () => {
37
+ test("re-renders when a write goes pending, then reaches the server", async () => {
38
+ env = createTestEnv({ transport: "poll" });
39
+ env.signIn({ userId: "u1" });
40
+ await env.start();
41
+ render(<Probe engine={asEngine(env.engine)} />);
42
+ expect(text()).toBe("0 not synced, 0 failed");
43
+
44
+ env.server.primeNextPushOutcome({ kind: "network" });
45
+ await act(async () => {
46
+ await env!.engine.insert("Knock", { door: "1" });
47
+ });
48
+ expect(text()).toBe("1 not synced, 0 failed");
49
+
50
+ await act(async () => {
51
+ await env!.engine.push();
52
+ });
53
+ await waitFor(() => expect(text()).toBe("0 not synced, 0 failed"));
54
+ });
55
+
56
+ test("re-renders when a write fails; retry and dismiss work from the hook", async () => {
57
+ env = createTestEnv({ transport: "poll" });
58
+ env.signIn({ userId: "u1" });
59
+ await env.start();
60
+ env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
61
+ render(<Probe engine={asEngine(env.engine)} />);
62
+
63
+ await act(async () => {
64
+ await env!.engine.insert("Knock", { door: "" }).catch(() => {});
65
+ });
66
+ expect(text()).toBe("0 not synced, 1 failed: door number is required");
67
+
68
+ await act(async () => {
69
+ await latest!.retry(latest!.failed[0]!.id, { door: "7" });
70
+ });
71
+ await waitFor(() => expect(text()).toBe("0 not synced, 0 failed"));
72
+
73
+ await act(async () => {
74
+ await env!.engine.insert("Knock", { door: "" }).catch(() => {});
75
+ });
76
+ expect(text()).toBe("0 not synced, 1 failed: door number is required");
77
+ act(() => {
78
+ expect(latest!.dismiss(latest!.failed[0]!.id)).toBe(true);
79
+ });
80
+ expect(text()).toBe("0 not synced, 0 failed");
81
+ });
82
+ });
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import {
4
+ OUTBOX_CHANNEL,
5
+ type Outbox,
6
+ type OutboxItem,
7
+ type Row,
8
+ type SyncEngine,
9
+ } from "@pylonsync/sync";
10
+ import { useCallback, useSyncExternalStore } from "react";
11
+ import { db } from "./db";
12
+
13
+ export type { Outbox, OutboxItem };
14
+
15
+ export interface UseOutboxReturn {
16
+ /** Writes the server has not applied yet: made offline, or waiting
17
+ * for a sign-in. Oldest first. */
18
+ pending: OutboxItem[];
19
+ /** Writes the server rejected, with `error` and `errorCode`. Oldest
20
+ * first. Each stays until it is retried or dismissed. */
21
+ failed: OutboxItem[];
22
+ /** Send a failed write again, with `data` merged over its fields.
23
+ * Resolves once the server applies it or it is queued offline;
24
+ * rejects with a `MutationRejectedError` when the server rejects it
25
+ * again or another user is signed in. */
26
+ retry: (id: string, data?: Row) => Promise<string>;
27
+ /** Drop a failed write. */
28
+ dismiss: (id: string) => boolean;
29
+ }
30
+
31
+ const EMPTY: Outbox = { pending: [], failed: [] };
32
+
33
+ /**
34
+ * The writes on this device that are not on the server: pending writes
35
+ * (offline, or waiting for a sign-in) and failed writes (the server
36
+ * rejected them). Re-renders when a write is queued, applied, rejected,
37
+ * retried or dismissed.
38
+ *
39
+ * ```tsx
40
+ * function Outbox() {
41
+ * const { pending, failed, retry, dismiss } = useOutbox();
42
+ * return (
43
+ * <>
44
+ * {pending.length > 0 && <p>{pending.length} not synced</p>}
45
+ * {failed.map((w) => (
46
+ * <div key={w.id}>
47
+ * {w.entity}: {w.error}
48
+ * <button onClick={() => retry(w.id)}>Retry</button>
49
+ * <button onClick={() => dismiss(w.id)}>Dismiss</button>
50
+ * </div>
51
+ * ))}
52
+ * </>
53
+ * );
54
+ * }
55
+ * ```
56
+ *
57
+ * Pass an explicit `engine` if your app uses a non-default sync
58
+ * instance; otherwise the hook reads the singleton wired up by `init()`.
59
+ */
60
+ export function useOutbox(engine?: SyncEngine): UseOutboxReturn {
61
+ const sync = engine ?? db.sync;
62
+ const outbox = useSyncExternalStore(
63
+ (cb) => sync.store.subscribe(cb, OUTBOX_CHANNEL),
64
+ () => sync.outbox(),
65
+ // The server has no queue.
66
+ () => EMPTY,
67
+ );
68
+ const retry = useCallback(
69
+ (id: string, data?: Row) => sync.retryWrite(id, data),
70
+ [sync],
71
+ );
72
+ const dismiss = useCallback((id: string) => sync.dismissWrite(id), [sync]);
73
+ return { pending: outbox.pending, failed: outbox.failed, retry, dismiss };
74
+ }
package/src/useSession.ts CHANGED
@@ -69,9 +69,11 @@ export function useSession(sync: SyncEngine): UseSessionReturn {
69
69
  // app writes a new token without going through `notifySessionChanged`,
70
70
  // our cached session still matches the old identity until the next
71
71
  // pull. A `storage` event covers the cross-tab case; a mount-time
72
- // refresh covers the same-tab write-then-mount race.
72
+ // check covers the same-tab write-then-mount race. The check fetches
73
+ // only when the session is not known for the current token, and joins
74
+ // a request in flight, so a screen of session readers makes one call.
73
75
  useEffect(() => {
74
- void sync.notifySessionChanged();
76
+ void sync.refreshSessionIfStale();
75
77
  const onStorage = (e: StorageEvent) => {
76
78
  // Only refresh when the key that actually changed looks like a
77
79
  // pylon token. Skip unrelated keys so we don't generate an
@@ -0,0 +1,37 @@
1
+ // useSyncProgress re-renders as a pull runs and ends.
2
+
3
+ import { afterEach, expect, test } from "bun:test";
4
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
5
+ import type { SyncEngine } from "@pylonsync/sync";
6
+ import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
7
+
8
+ import { useSyncProgress } from "./useSyncProgress";
9
+
10
+ let env: TestEnv | null = null;
11
+ afterEach(async () => {
12
+ cleanup();
13
+ if (env) {
14
+ await env.dispose();
15
+ env = null;
16
+ }
17
+ });
18
+
19
+ function Probe({ engine }: { engine: SyncEngine }) {
20
+ const p = useSyncProgress(engine);
21
+ return <span data-testid="p">{`${p.pulling} ${p.snapshot} ${p.rows} ${p.synced}`}</span>;
22
+ }
23
+
24
+ test("reports a snapshot pull's rows, then synced", async () => {
25
+ env = createTestEnv({ transport: "poll" });
26
+ env.server.snapshotMode = true;
27
+ env.server.seed("Lead", Array.from({ length: 1500 }, (_, i) => ({ id: `l${i}` })));
28
+ env.signIn({ userId: "u1" });
29
+ const engine = env.engine as unknown as SyncEngine;
30
+ render(<Probe engine={engine} />);
31
+ expect(screen.getByTestId("p").textContent).toBe("false false 0 false");
32
+
33
+ await act(async () => {
34
+ await env!.engine.start();
35
+ });
36
+ await waitFor(() => expect(screen.getByTestId("p").textContent).toBe("false true 1500 true"));
37
+ });
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import {
4
+ SYNC_PROGRESS_CHANNEL,
5
+ type SyncEngine,
6
+ type SyncProgress,
7
+ } from "@pylonsync/sync";
8
+ import { useSyncExternalStore } from "react";
9
+ import { db } from "./db";
10
+
11
+ export type { SyncProgress };
12
+
13
+ const SERVER: SyncProgress = {
14
+ pulling: false,
15
+ snapshot: false,
16
+ rows: 0,
17
+ pages: 0,
18
+ synced: false,
19
+ };
20
+
21
+ /**
22
+ * How far the sync engine's current pull has got: `pulling`, `snapshot`
23
+ * (a first sync or a far-behind catch-up), `rows` and `pages` received,
24
+ * and `synced` (a pull completed since the last replica reset).
25
+ *
26
+ * The session resolves as soon as `/api/auth/me` answers, before the
27
+ * replica has filled. Render the signed-in screen from `useSession()` and
28
+ * use this for a progress line while the data arrives. `useSyncStatus()`
29
+ * reports the connection instead.
30
+ *
31
+ * ```tsx
32
+ * function SyncLine() {
33
+ * const { pulling, snapshot, rows } = useSyncProgress();
34
+ * if (!pulling || !snapshot) return null;
35
+ * return <p>Loading data: {rows} records</p>;
36
+ * }
37
+ * ```
38
+ */
39
+ export function useSyncProgress(engine?: SyncEngine): SyncProgress {
40
+ const sync = engine ?? db.sync;
41
+ return useSyncExternalStore(
42
+ (cb) => sync.store.subscribe(cb, SYNC_PROGRESS_CHANNEL),
43
+ () => sync.syncProgress(),
44
+ () => SERVER,
45
+ );
46
+ }