@pylonsync/react 0.22.50 → 0.22.59
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/dist/AppUpdateBar.d.ts +21 -0
- package/dist/appUpdate.d.ts +166 -0
- package/dist/index.d.ts +8 -0
- package/dist/offlineWorker.d.ts +4 -0
- package/dist/queryFilter.d.ts +13 -0
- package/dist/useAppUpdate.d.ts +34 -0
- package/dist/useSyncProgress.d.ts +21 -0
- package/package.json +4 -4
- package/src/AppUpdateBar.tsx +54 -0
- package/src/Link.tsx +21 -3
- package/src/appUpdate.test.ts +461 -0
- package/src/appUpdate.ts +602 -0
- package/src/hooks.ts +2 -68
- package/src/index.ts +10 -0
- package/src/offlineWorker.test.ts +23 -0
- package/src/offlineWorker.ts +8 -0
- package/src/query-filter-cases.json +23 -0
- package/src/queryFilter.test.ts +45 -0
- package/src/queryFilter.ts +138 -0
- package/src/useAppUpdate.test.tsx +98 -0
- package/src/useAppUpdate.ts +49 -0
- package/src/useSession.ts +4 -2
- package/src/useSyncProgress.test.tsx +37 -0
- package/src/useSyncProgress.ts +46 -0
package/src/index.ts
CHANGED
|
@@ -190,6 +190,8 @@ export type { UseSessionReturn, ResolvedSession } from "./useSession";
|
|
|
190
190
|
|
|
191
191
|
export { useSyncStatus } from "./useSyncStatus";
|
|
192
192
|
export type { SyncConnectionStatus } from "./useSyncStatus";
|
|
193
|
+
export { useSyncProgress } from "./useSyncProgress";
|
|
194
|
+
export type { SyncProgress } from "./useSyncProgress";
|
|
193
195
|
|
|
194
196
|
// Writes not yet on the server, and writes the server rejected.
|
|
195
197
|
export { useOutbox } from "./useOutbox";
|
|
@@ -205,6 +207,14 @@ export { UploadQueueFullError, UploadRejectedError } from "@pylonsync/sync";
|
|
|
205
207
|
export { registerOfflineWorker } from "./offlineWorker";
|
|
206
208
|
export type { OfflineWorkerOptions } from "./offlineWorker";
|
|
207
209
|
|
|
210
|
+
// App updates: a newer build is deployed, load it.
|
|
211
|
+
export { useAppUpdate } from "./useAppUpdate";
|
|
212
|
+
export type { UseAppUpdateReturn } from "./useAppUpdate";
|
|
213
|
+
export { AppUpdateBar } from "./AppUpdateBar";
|
|
214
|
+
export type { AppUpdateBarProps } from "./AppUpdateBar";
|
|
215
|
+
export { createAppUpdate, AppUpdater } from "./appUpdate";
|
|
216
|
+
export type { AppBuild, AppUpdateState, AppUpdateEnv } from "./appUpdate";
|
|
217
|
+
|
|
208
218
|
// One-liner API
|
|
209
219
|
export { db, init, getSync, setSessionToken } from "./db";
|
|
210
220
|
|
|
@@ -85,3 +85,26 @@ test("a browser without service workers is a no-op", async () => {
|
|
|
85
85
|
await registerOfflineWorker({ optedIn: true, production: true, navigator: {} }),
|
|
86
86
|
).toBeNull();
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test("passes the page's build to useAppUpdate, in production and in dev", async () => {
|
|
90
|
+
const { appUpdate } = await import("./appUpdate");
|
|
91
|
+
const f = fakeContainer();
|
|
92
|
+
await registerOfflineWorker({
|
|
93
|
+
optedIn: true,
|
|
94
|
+
production: true,
|
|
95
|
+
navigator: { serviceWorker: f.container },
|
|
96
|
+
whenLoaded: loaded,
|
|
97
|
+
build: { version: "abc", builtAt: "2026-10-22T10:00:00.000Z" },
|
|
98
|
+
});
|
|
99
|
+
expect(appUpdate.getSnapshot().version).toBe("abc");
|
|
100
|
+
expect(appUpdate.getSnapshot().builtAt).toBe("2026-10-22T10:00:00.000Z");
|
|
101
|
+
|
|
102
|
+
// Under pylon dev the updater stays empty.
|
|
103
|
+
await registerOfflineWorker({
|
|
104
|
+
optedIn: true,
|
|
105
|
+
production: false,
|
|
106
|
+
navigator: { serviceWorker: f.container },
|
|
107
|
+
build: { version: "abc", builtAt: null },
|
|
108
|
+
});
|
|
109
|
+
expect(appUpdate.getSnapshot().version).toBeNull();
|
|
110
|
+
});
|
package/src/offlineWorker.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// `pylon dev`: a worker serves cached build files first, which would hide
|
|
8
8
|
// every edit until the next install.
|
|
9
9
|
|
|
10
|
+
import { configureAppUpdate, type AppBuild } from "./appUpdate";
|
|
11
|
+
|
|
10
12
|
const WORKER_URL = "/pylon-sw.js";
|
|
11
13
|
|
|
12
14
|
export interface OfflineWorkerOptions {
|
|
@@ -23,6 +25,9 @@ export interface OfflineWorkerOptions {
|
|
|
23
25
|
/** Resolves when the page has loaded, so the worker's downloads do not
|
|
24
26
|
* compete with the page's own. Defaults to the window `load` event. */
|
|
25
27
|
whenLoaded?: () => Promise<void>;
|
|
28
|
+
/** The build this page runs, from `__PYLON_DATA__`. `useAppUpdate`
|
|
29
|
+
* compares it with the latest build. */
|
|
30
|
+
build?: AppBuild;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/** The parts of `ServiceWorkerContainer` this module uses. */
|
|
@@ -69,6 +74,9 @@ function isPylonWorker(reg: RegistrationLike): boolean {
|
|
|
69
74
|
export async function registerOfflineWorker(
|
|
70
75
|
opts: OfflineWorkerOptions,
|
|
71
76
|
): Promise<unknown | null> {
|
|
77
|
+
// Before the service worker checks: a browser without service workers
|
|
78
|
+
// still learns about new builds from `/_pylon/version`.
|
|
79
|
+
if (opts.build) configureAppUpdate(opts.build, opts.optedIn && opts.production);
|
|
72
80
|
const nav =
|
|
73
81
|
opts.navigator ??
|
|
74
82
|
(typeof navigator !== "undefined" ? (navigator as any) : undefined);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Filters answered from the local replica must match the server's answer. packages/react/src/queryFilter.test.ts runs these on the client; crates/runtime/tests/query_filter_parity.rs runs them on the server. Rows are stored as the server returns them (datetimes in canonical UTC form).",
|
|
3
|
+
"rows": [
|
|
4
|
+
{ "key": "k1", "title": "apple", "occurredAt": "2026-09-30T23:59:59.999Z", "rank": 1 },
|
|
5
|
+
{ "key": "k2", "title": "Banana", "occurredAt": "2026-10-01T00:00:00.000Z", "rank": 2 },
|
|
6
|
+
{ "key": "k3", "title": "cherry", "occurredAt": "2026-10-01T12:30:00.000Z", "rank": 3 },
|
|
7
|
+
{ "key": "k4", "title": "banana", "occurredAt": "2026-10-02T00:00:00.000Z", "rank": 4 },
|
|
8
|
+
{ "key": "k5", "title": "", "occurredAt": null, "rank": 5 }
|
|
9
|
+
],
|
|
10
|
+
"cases": [
|
|
11
|
+
{ "where": { "occurredAt": { "$gte": "2026-10-01T00:00:00Z" } }, "keys": ["k2", "k3", "k4"] },
|
|
12
|
+
{ "where": { "occurredAt": { "$lt": "2026-10-02T00:00:00.000Z" } }, "keys": ["k1", "k2", "k3"] },
|
|
13
|
+
{ "where": { "occurredAt": { "$gte": "2026-09-30T20:00:00-04:00" } }, "keys": ["k2", "k3", "k4"] },
|
|
14
|
+
{ "where": { "occurredAt": { "$gte": "2026-10-01T00:00:00.000Z", "$lt": "2026-10-01T23:00:00Z" } }, "keys": ["k2", "k3"] },
|
|
15
|
+
{ "where": { "occurredAt": { "$lte": "2026-09-30T23:59:59.999Z" } }, "keys": ["k1"] },
|
|
16
|
+
{ "where": { "occurredAt": { "$gt": "2026-10-01T00:00:00.000Z" } }, "keys": ["k3", "k4"] },
|
|
17
|
+
{ "where": { "occurredAt": { "$gte": "2026-10-01" } }, "keys": ["k2", "k3", "k4"] },
|
|
18
|
+
{ "where": { "title": { "$gte": "b" } }, "keys": ["k3", "k4"] },
|
|
19
|
+
{ "where": { "title": { "$lt": "a" } }, "keys": ["k2", "k5"] },
|
|
20
|
+
{ "where": { "rank": { "$gt": 2 } }, "keys": ["k3", "k4", "k5"] },
|
|
21
|
+
{ "where": { "rank": { "$gte": 2, "$lte": 3 } }, "keys": ["k2", "k3"] }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -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 -> 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
|
+
}
|
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
|
-
//
|
|
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.
|
|
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
|
+
}
|