@pylonsync/react 0.22.50 → 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.
- 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/appUpdate.ts
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// App updates for apps that set `buildManifest({ offline })`.
|
|
4
|
+
//
|
|
5
|
+
// A deploy installs a new service worker, and the browser keeps it waiting
|
|
6
|
+
// until every tab of the app is closed. An app on the iOS home screen can
|
|
7
|
+
// stay open in the background for days, so it runs the old build with no
|
|
8
|
+
// sign of the new one. This module tells the page that a new build exists
|
|
9
|
+
// and loads it on request:
|
|
10
|
+
//
|
|
11
|
+
// - The page knows its own build: the SSR client runtime passes the
|
|
12
|
+
// version and build time from `__PYLON_DATA__` to
|
|
13
|
+
// `registerOfflineWorker({ build })`, which calls `configureAppUpdate`.
|
|
14
|
+
// - The latest build comes from `/_pylon/version` (no-store), checked when
|
|
15
|
+
// the page becomes visible and every five minutes while it is visible.
|
|
16
|
+
// - A waiting service worker is a new build too. The page asks it for its
|
|
17
|
+
// version (`pylon:version`), so a worker with the same version (another
|
|
18
|
+
// machine wrote the same build) does not count.
|
|
19
|
+
// - `apply()` waits until the outbox has no pending writes, tells the
|
|
20
|
+
// waiting worker to take over (`pylon:skip-waiting`), and reloads once
|
|
21
|
+
// when it does.
|
|
22
|
+
// - On an iOS home-screen app, a change to the manifest's name, icons,
|
|
23
|
+
// display mode or start URL sets `reinstallRecommended`: iOS reads those
|
|
24
|
+
// only when the app is added.
|
|
25
|
+
//
|
|
26
|
+
// Under `pylon dev`, during SSR, and in an app without `offline`, the state
|
|
27
|
+
// stays empty and `apply()` does nothing.
|
|
28
|
+
|
|
29
|
+
import { OUTBOX_CHANNEL } from "@pylonsync/sync";
|
|
30
|
+
import { peekActiveEngine } from "./engine-registry";
|
|
31
|
+
|
|
32
|
+
/** The path the runtime serves the latest build's version at. */
|
|
33
|
+
export const VERSION_URL = "/_pylon/version";
|
|
34
|
+
|
|
35
|
+
/** How often a visible page checks for a new build. */
|
|
36
|
+
export const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** How long `apply()` waits for a new worker to install or take over
|
|
39
|
+
* before it reloads anyway. */
|
|
40
|
+
export const WORKER_WAIT_MS = 30 * 1000;
|
|
41
|
+
|
|
42
|
+
/** How long a waiting worker has to answer `pylon:version`. */
|
|
43
|
+
const VERSION_ASK_MS = 1000;
|
|
44
|
+
|
|
45
|
+
/** A reload that lands on the same build within this window does not
|
|
46
|
+
* reload again. */
|
|
47
|
+
export const RELOAD_GUARD_MS = 60 * 1000;
|
|
48
|
+
|
|
49
|
+
/** sessionStorage: the build `apply()` reloaded from, and when. */
|
|
50
|
+
const RELOAD_KEY = "pylon:app-update:reload";
|
|
51
|
+
|
|
52
|
+
/** localStorage: the install identity the home-screen app was added with. */
|
|
53
|
+
const INSTALL_KEY = "pylon:app-update:install";
|
|
54
|
+
|
|
55
|
+
/** One build of the app. */
|
|
56
|
+
export interface AppBuild {
|
|
57
|
+
/** The service worker version: a hash of the build. */
|
|
58
|
+
version: string;
|
|
59
|
+
/** When the build was made (ISO 8601), or null when unknown. */
|
|
60
|
+
builtAt: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AppUpdateState {
|
|
64
|
+
/** The build this page runs. Null during SSR and in an app without
|
|
65
|
+
* `buildManifest({ offline })`. */
|
|
66
|
+
version: string | null;
|
|
67
|
+
builtAt: string | null;
|
|
68
|
+
/** The latest build the server reported. Null until the first check
|
|
69
|
+
* succeeds. */
|
|
70
|
+
latestVersion: string | null;
|
|
71
|
+
latestBuiltAt: string | null;
|
|
72
|
+
/** A newer build exists. `apply()` loads it. */
|
|
73
|
+
updateReady: boolean;
|
|
74
|
+
/** The app is on the iOS home screen, and its name, icons, display mode
|
|
75
|
+
* or start URL changed since it was added. The user must remove the app
|
|
76
|
+
* and add it again to see the change. */
|
|
77
|
+
reinstallRecommended: boolean;
|
|
78
|
+
/** `apply()` is running. */
|
|
79
|
+
applying: boolean;
|
|
80
|
+
/** `apply()` is waiting for pending writes to reach the server. */
|
|
81
|
+
waitingForSync: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The parts of `ServiceWorker` this module uses. */
|
|
85
|
+
export interface WorkerLike {
|
|
86
|
+
state?: string;
|
|
87
|
+
postMessage(message: unknown, transfer?: unknown[]): void;
|
|
88
|
+
addEventListener?(type: "statechange", listener: () => void): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The parts of `ServiceWorkerRegistration` this module uses. */
|
|
92
|
+
export interface UpdateRegistrationLike {
|
|
93
|
+
waiting?: WorkerLike | null;
|
|
94
|
+
installing?: WorkerLike | null;
|
|
95
|
+
update?(): Promise<unknown>;
|
|
96
|
+
addEventListener?(type: "updatefound", listener: () => void): void;
|
|
97
|
+
removeEventListener?(type: "updatefound", listener: () => void): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The parts of `ServiceWorkerContainer` this module uses. */
|
|
101
|
+
export interface UpdateContainerLike {
|
|
102
|
+
controller?: unknown;
|
|
103
|
+
getRegistration?(scope?: string): Promise<UpdateRegistrationLike | undefined>;
|
|
104
|
+
addEventListener?(type: "controllerchange", listener: () => void): void;
|
|
105
|
+
removeEventListener?(type: "controllerchange", listener: () => void): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface StorageLike {
|
|
109
|
+
getItem(key: string): string | null;
|
|
110
|
+
setItem(key: string, value: string): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The pending writes `apply()` waits for. */
|
|
114
|
+
export interface PendingWrites {
|
|
115
|
+
count(): number;
|
|
116
|
+
subscribe(listener: () => void): () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Everything the updater touches outside itself. Tests pass their own;
|
|
120
|
+
* the browser defaults come from `browserEnv`. */
|
|
121
|
+
export interface AppUpdateEnv {
|
|
122
|
+
serviceWorker: UpdateContainerLike | null;
|
|
123
|
+
fetch(url: string, init: RequestInit): Promise<{ ok: boolean; json(): Promise<unknown> }>;
|
|
124
|
+
document: {
|
|
125
|
+
visibilityState: string;
|
|
126
|
+
addEventListener(type: "visibilitychange", listener: () => void): void;
|
|
127
|
+
removeEventListener(type: "visibilitychange", listener: () => void): void;
|
|
128
|
+
} | null;
|
|
129
|
+
setInterval(fn: () => void, ms: number): unknown;
|
|
130
|
+
clearInterval(handle: unknown): void;
|
|
131
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
132
|
+
clearTimeout(handle: unknown): void;
|
|
133
|
+
now(): number;
|
|
134
|
+
reload(): void;
|
|
135
|
+
sessionStorage: StorageLike | null;
|
|
136
|
+
localStorage: StorageLike | null;
|
|
137
|
+
/** The page runs as an iOS home-screen app (`navigator.standalone`). */
|
|
138
|
+
iosStandalone: boolean;
|
|
139
|
+
/** The sync engine's pending writes, or null when the app has none. */
|
|
140
|
+
pendingWrites(): PendingWrites | null;
|
|
141
|
+
/** Asks a waiting worker for its version. Null when it does not answer. */
|
|
142
|
+
askVersion(worker: WorkerLike): Promise<string | null>;
|
|
143
|
+
warn(message: string): void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const INERT: AppUpdateState = Object.freeze({
|
|
147
|
+
version: null,
|
|
148
|
+
builtAt: null,
|
|
149
|
+
latestVersion: null,
|
|
150
|
+
latestBuiltAt: null,
|
|
151
|
+
updateReady: false,
|
|
152
|
+
reinstallRecommended: false,
|
|
153
|
+
applying: false,
|
|
154
|
+
waitingForSync: false,
|
|
155
|
+
}) as AppUpdateState;
|
|
156
|
+
|
|
157
|
+
/** The state during SSR and before the client runtime configured a build. */
|
|
158
|
+
export const INERT_APP_UPDATE_STATE: AppUpdateState = INERT;
|
|
159
|
+
|
|
160
|
+
interface VersionReport {
|
|
161
|
+
version: string;
|
|
162
|
+
builtAt: string | null;
|
|
163
|
+
install: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readJson(storage: StorageLike | null, key: string): any {
|
|
167
|
+
try {
|
|
168
|
+
const raw = storage?.getItem(key);
|
|
169
|
+
return raw ? JSON.parse(raw) : null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function writeItem(storage: StorageLike | null, key: string, value: string): void {
|
|
176
|
+
try {
|
|
177
|
+
storage?.setItem(key, value);
|
|
178
|
+
} catch {
|
|
179
|
+
// Storage full or blocked: the guard and the install record are
|
|
180
|
+
// best-effort.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Checks for new builds and loads them. One per page; see `appUpdate`. */
|
|
185
|
+
export class AppUpdater {
|
|
186
|
+
private build: AppBuild | null = null;
|
|
187
|
+
private production = false;
|
|
188
|
+
private envFactory: () => AppUpdateEnv | null;
|
|
189
|
+
private env: AppUpdateEnv | null = null;
|
|
190
|
+
private listeners = new Set<() => void>();
|
|
191
|
+
private snapshot: AppUpdateState = INERT;
|
|
192
|
+
|
|
193
|
+
private latest: VersionReport | null = null;
|
|
194
|
+
private waitingVersion: string | null = null;
|
|
195
|
+
private reinstall = false;
|
|
196
|
+
private applying = false;
|
|
197
|
+
private waitingForSync = false;
|
|
198
|
+
|
|
199
|
+
private registration: UpdateRegistrationLike | null = null;
|
|
200
|
+
private running = false;
|
|
201
|
+
private interval: unknown = null;
|
|
202
|
+
private checking: Promise<void> | null = null;
|
|
203
|
+
private applyRun: Promise<void> | null = null;
|
|
204
|
+
private reloading = false;
|
|
205
|
+
|
|
206
|
+
constructor(envFactory: () => AppUpdateEnv | null) {
|
|
207
|
+
this.envFactory = envFactory;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Set the build this page runs. `production` is false under `pylon
|
|
211
|
+
* dev`, which leaves the updater inert. */
|
|
212
|
+
configure(build: AppBuild | null, production: boolean): void {
|
|
213
|
+
this.build = build;
|
|
214
|
+
this.production = production;
|
|
215
|
+
this.emit();
|
|
216
|
+
if (this.listeners.size > 0) this.start();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
getSnapshot = (): AppUpdateState => this.snapshot;
|
|
220
|
+
|
|
221
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
222
|
+
this.listeners.add(listener);
|
|
223
|
+
if (this.listeners.size === 1) this.start();
|
|
224
|
+
return () => {
|
|
225
|
+
this.listeners.delete(listener);
|
|
226
|
+
if (this.listeners.size === 0) this.stop();
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Ask the server for the latest build now. */
|
|
231
|
+
check = (): Promise<void> => {
|
|
232
|
+
const env = this.activeEnv();
|
|
233
|
+
if (!env) return Promise.resolve();
|
|
234
|
+
if (!this.checking) {
|
|
235
|
+
this.checking = this.runCheck(env).finally(() => {
|
|
236
|
+
this.checking = null;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return this.checking;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Load the newest build: wait until the outbox has no pending writes,
|
|
244
|
+
* let the waiting service worker take over, and reload the page once.
|
|
245
|
+
* Does nothing under `pylon dev`, during SSR, or right after a reload
|
|
246
|
+
* that did not change the build (so a page that calls `apply()` on its
|
|
247
|
+
* own cannot reload in a loop).
|
|
248
|
+
*/
|
|
249
|
+
apply = (): Promise<void> => {
|
|
250
|
+
const env = this.activeEnv();
|
|
251
|
+
if (!env) return Promise.resolve();
|
|
252
|
+
if (!this.applyRun) {
|
|
253
|
+
this.applying = true;
|
|
254
|
+
this.emit();
|
|
255
|
+
this.applyRun = this.runApply(env).finally(() => {
|
|
256
|
+
this.applyRun = null;
|
|
257
|
+
this.applying = false;
|
|
258
|
+
this.waitingForSync = false;
|
|
259
|
+
this.emit();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return this.applyRun;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
private activeEnv(): AppUpdateEnv | null {
|
|
266
|
+
if (!this.build || !this.production) return null;
|
|
267
|
+
if (!this.env) this.env = this.envFactory();
|
|
268
|
+
return this.env;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private start(): void {
|
|
272
|
+
const env = this.activeEnv();
|
|
273
|
+
if (!env || this.running) return;
|
|
274
|
+
this.running = true;
|
|
275
|
+
env.document?.addEventListener("visibilitychange", this.onVisibility);
|
|
276
|
+
this.interval = env.setInterval(() => {
|
|
277
|
+
if (!env.document || env.document.visibilityState === "visible") void this.check();
|
|
278
|
+
}, CHECK_INTERVAL_MS);
|
|
279
|
+
void this.check();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private stop(): void {
|
|
283
|
+
if (!this.running || !this.env) return;
|
|
284
|
+
this.running = false;
|
|
285
|
+
this.env.document?.removeEventListener("visibilitychange", this.onVisibility);
|
|
286
|
+
this.env.clearInterval(this.interval);
|
|
287
|
+
this.interval = null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private onVisibility = (): void => {
|
|
291
|
+
if (this.env?.document?.visibilityState === "visible") void this.check();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
private emit(): void {
|
|
295
|
+
const build = this.production ? this.build : null;
|
|
296
|
+
const current = build?.version ?? null;
|
|
297
|
+
const latestVersion = build ? (this.latest?.version ?? null) : null;
|
|
298
|
+
const next: AppUpdateState = build
|
|
299
|
+
? {
|
|
300
|
+
version: build.version,
|
|
301
|
+
builtAt: build.builtAt,
|
|
302
|
+
latestVersion,
|
|
303
|
+
latestBuiltAt: this.latest?.builtAt ?? null,
|
|
304
|
+
updateReady:
|
|
305
|
+
(latestVersion !== null && latestVersion !== current) ||
|
|
306
|
+
(this.waitingVersion !== null && this.waitingVersion !== current),
|
|
307
|
+
reinstallRecommended: this.reinstall,
|
|
308
|
+
applying: this.applying,
|
|
309
|
+
waitingForSync: this.waitingForSync,
|
|
310
|
+
}
|
|
311
|
+
: INERT;
|
|
312
|
+
const prev = this.snapshot;
|
|
313
|
+
const same = (Object.keys(next) as Array<keyof AppUpdateState>).every(
|
|
314
|
+
(k) => prev[k] === next[k],
|
|
315
|
+
);
|
|
316
|
+
if (same) return;
|
|
317
|
+
this.snapshot = next;
|
|
318
|
+
for (const listener of this.listeners) listener();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async runCheck(env: AppUpdateEnv): Promise<void> {
|
|
322
|
+
try {
|
|
323
|
+
const res = await env.fetch(VERSION_URL, {
|
|
324
|
+
cache: "no-store",
|
|
325
|
+
credentials: "same-origin",
|
|
326
|
+
});
|
|
327
|
+
if (res.ok) {
|
|
328
|
+
const body: any = await res.json();
|
|
329
|
+
if (body && typeof body.version === "string") {
|
|
330
|
+
this.latest = {
|
|
331
|
+
version: body.version,
|
|
332
|
+
builtAt: typeof body.builtAt === "string" ? body.builtAt : null,
|
|
333
|
+
install: typeof body.install === "string" ? body.install : null,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
// Offline, or the server is restarting: the next check tries again.
|
|
339
|
+
}
|
|
340
|
+
const reg = await this.watchRegistration(env);
|
|
341
|
+
if (
|
|
342
|
+
reg &&
|
|
343
|
+
this.latest &&
|
|
344
|
+
this.latest.version !== this.build?.version &&
|
|
345
|
+
!reg.waiting &&
|
|
346
|
+
!reg.installing
|
|
347
|
+
) {
|
|
348
|
+
// The server has a new build the browser has not fetched the worker
|
|
349
|
+
// for yet. Fetch it now rather than on the next navigation.
|
|
350
|
+
try {
|
|
351
|
+
await reg.update?.();
|
|
352
|
+
} catch {
|
|
353
|
+
// The worker script failed to download; the next check tries again.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await this.readWaiting(env);
|
|
357
|
+
this.readInstall(env);
|
|
358
|
+
this.emit();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The Pylon worker's registration, with an `updatefound` listener that
|
|
362
|
+
* records a new worker once it is installed. */
|
|
363
|
+
private async watchRegistration(env: AppUpdateEnv): Promise<UpdateRegistrationLike | null> {
|
|
364
|
+
if (this.registration) return this.registration;
|
|
365
|
+
const container = env.serviceWorker;
|
|
366
|
+
if (!container?.getRegistration) return null;
|
|
367
|
+
let reg: UpdateRegistrationLike | undefined;
|
|
368
|
+
try {
|
|
369
|
+
reg = await container.getRegistration("/");
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (!reg) return null;
|
|
374
|
+
this.registration = reg;
|
|
375
|
+
reg.addEventListener?.("updatefound", () => {
|
|
376
|
+
const worker = reg!.installing;
|
|
377
|
+
worker?.addEventListener?.("statechange", () => {
|
|
378
|
+
if (worker.state === "installed") {
|
|
379
|
+
void this.readWaiting(env).then(() => this.emit());
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
return reg;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async readWaiting(env: AppUpdateEnv): Promise<void> {
|
|
387
|
+
const waiting = this.registration?.waiting;
|
|
388
|
+
if (!waiting) {
|
|
389
|
+
this.waitingVersion = null;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const version = await env.askVersion(waiting);
|
|
393
|
+
// A worker that does not answer is still a different script: count it
|
|
394
|
+
// as a new build.
|
|
395
|
+
this.waitingVersion = version ?? "unknown";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private readInstall(env: AppUpdateEnv): void {
|
|
399
|
+
const latest = this.latest;
|
|
400
|
+
if (!env.iosStandalone || !latest?.install) {
|
|
401
|
+
this.reinstall = false;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const stored = readJson(env.localStorage, INSTALL_KEY);
|
|
405
|
+
if (!stored || typeof stored.install !== "string") {
|
|
406
|
+
// First check since the app was added (iOS gives each home-screen app
|
|
407
|
+
// its own storage, so adding it again clears this). Record the
|
|
408
|
+
// identity of the build it runs, not a newer one.
|
|
409
|
+
if (latest.version === this.build?.version) {
|
|
410
|
+
writeItem(env.localStorage, INSTALL_KEY, JSON.stringify({ install: latest.install }));
|
|
411
|
+
}
|
|
412
|
+
this.reinstall = false;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
this.reinstall = stored.install !== latest.install;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async runApply(env: AppUpdateEnv): Promise<void> {
|
|
419
|
+
const from = this.build!.version;
|
|
420
|
+
const last = readJson(env.sessionStorage, RELOAD_KEY);
|
|
421
|
+
if (
|
|
422
|
+
last &&
|
|
423
|
+
last.from === from &&
|
|
424
|
+
typeof last.at === "number" &&
|
|
425
|
+
env.now() - last.at < RELOAD_GUARD_MS
|
|
426
|
+
) {
|
|
427
|
+
env.warn(
|
|
428
|
+
`[pylon] apply() reloaded ${Math.round((env.now() - last.at) / 1000)}s ago ` +
|
|
429
|
+
`and the page still runs build ${from}. Not reloading again.`,
|
|
430
|
+
);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await this.drainOutbox(env);
|
|
435
|
+
|
|
436
|
+
const reg = await this.watchRegistration(env);
|
|
437
|
+
if (reg && !reg.waiting) {
|
|
438
|
+
const behind = this.latest !== null && this.latest.version !== from;
|
|
439
|
+
if (reg.installing || behind) {
|
|
440
|
+
if (!reg.installing) {
|
|
441
|
+
try {
|
|
442
|
+
await reg.update?.();
|
|
443
|
+
} catch {
|
|
444
|
+
// Reload without the new worker: navigations are network
|
|
445
|
+
// first, so the page still gets the new build.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
await this.waitForInstalled(env, reg);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
writeItem(env.sessionStorage, RELOAD_KEY, JSON.stringify({ from, at: env.now() }));
|
|
453
|
+
|
|
454
|
+
const waiting = reg?.waiting;
|
|
455
|
+
const container = env.serviceWorker;
|
|
456
|
+
if (waiting && container?.controller) {
|
|
457
|
+
await new Promise<void>((resolve) => {
|
|
458
|
+
let timer: unknown = null;
|
|
459
|
+
const done = () => {
|
|
460
|
+
container.removeEventListener?.("controllerchange", done);
|
|
461
|
+
env.clearTimeout(timer);
|
|
462
|
+
resolve();
|
|
463
|
+
};
|
|
464
|
+
container.addEventListener?.("controllerchange", done);
|
|
465
|
+
timer = env.setTimeout(done, WORKER_WAIT_MS);
|
|
466
|
+
waiting.postMessage({ type: "pylon:skip-waiting" });
|
|
467
|
+
});
|
|
468
|
+
} else if (waiting) {
|
|
469
|
+
waiting.postMessage({ type: "pylon:skip-waiting" });
|
|
470
|
+
}
|
|
471
|
+
this.reload(env);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private reload(env: AppUpdateEnv): void {
|
|
475
|
+
if (this.reloading) return;
|
|
476
|
+
this.reloading = true;
|
|
477
|
+
env.reload();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private drainOutbox(env: AppUpdateEnv): Promise<void> {
|
|
481
|
+
const writes = env.pendingWrites();
|
|
482
|
+
if (!writes || writes.count() === 0) return Promise.resolve();
|
|
483
|
+
this.waitingForSync = true;
|
|
484
|
+
this.emit();
|
|
485
|
+
return new Promise((resolve) => {
|
|
486
|
+
let unsubscribe: (() => void) | null = null;
|
|
487
|
+
let drained = false;
|
|
488
|
+
const onChange = () => {
|
|
489
|
+
if (drained || writes.count() > 0) return;
|
|
490
|
+
drained = true;
|
|
491
|
+
unsubscribe?.();
|
|
492
|
+
this.waitingForSync = false;
|
|
493
|
+
this.emit();
|
|
494
|
+
resolve();
|
|
495
|
+
};
|
|
496
|
+
unsubscribe = writes.subscribe(onChange);
|
|
497
|
+
// The last write may have landed between the count and the subscribe.
|
|
498
|
+
onChange();
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private waitForInstalled(env: AppUpdateEnv, reg: UpdateRegistrationLike): Promise<void> {
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
let finished = false;
|
|
505
|
+
let timer: unknown = null;
|
|
506
|
+
const finish = () => {
|
|
507
|
+
if (finished) return;
|
|
508
|
+
finished = true;
|
|
509
|
+
env.clearTimeout(timer);
|
|
510
|
+
reg.removeEventListener?.("updatefound", onFound);
|
|
511
|
+
resolve();
|
|
512
|
+
};
|
|
513
|
+
const watch = (worker: WorkerLike | null | undefined) => {
|
|
514
|
+
worker?.addEventListener?.("statechange", () => {
|
|
515
|
+
if (worker.state !== "installing") finish();
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
const onFound = () => watch(reg.installing);
|
|
519
|
+
if (reg.waiting) return finish();
|
|
520
|
+
timer = env.setTimeout(finish, WORKER_WAIT_MS);
|
|
521
|
+
reg.addEventListener?.("updatefound", onFound);
|
|
522
|
+
watch(reg.installing);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function safeStorage(get: () => StorageLike): StorageLike | null {
|
|
528
|
+
try {
|
|
529
|
+
return get();
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The browser environment, or null outside a browser. */
|
|
536
|
+
export function browserEnv(): AppUpdateEnv | null {
|
|
537
|
+
if (typeof window === "undefined" || typeof document === "undefined") return null;
|
|
538
|
+
const nav: any = typeof navigator !== "undefined" ? navigator : {};
|
|
539
|
+
return {
|
|
540
|
+
serviceWorker: nav.serviceWorker ?? null,
|
|
541
|
+
fetch: (url, init) => fetch(url, init),
|
|
542
|
+
document,
|
|
543
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
544
|
+
clearInterval: (h) => clearInterval(h as any),
|
|
545
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
546
|
+
clearTimeout: (h) => clearTimeout(h as any),
|
|
547
|
+
now: () => Date.now(),
|
|
548
|
+
reload: () => window.location.reload(),
|
|
549
|
+
sessionStorage: safeStorage(() => window.sessionStorage),
|
|
550
|
+
localStorage: safeStorage(() => window.localStorage),
|
|
551
|
+
iosStandalone: nav.standalone === true,
|
|
552
|
+
pendingWrites: () => {
|
|
553
|
+
const engine = peekActiveEngine();
|
|
554
|
+
if (!engine) return null;
|
|
555
|
+
return {
|
|
556
|
+
count: () => engine.outbox().pending.length,
|
|
557
|
+
subscribe: (listener) => engine.store.subscribe(listener, OUTBOX_CHANNEL),
|
|
558
|
+
};
|
|
559
|
+
},
|
|
560
|
+
askVersion: (worker) =>
|
|
561
|
+
new Promise((resolve) => {
|
|
562
|
+
if (typeof MessageChannel === "undefined") return resolve(null);
|
|
563
|
+
const channel = new MessageChannel();
|
|
564
|
+
const timer = setTimeout(() => {
|
|
565
|
+
channel.port1.close();
|
|
566
|
+
resolve(null);
|
|
567
|
+
}, VERSION_ASK_MS);
|
|
568
|
+
channel.port1.onmessage = (event) => {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
channel.port1.close();
|
|
571
|
+
const version = event.data?.version;
|
|
572
|
+
resolve(typeof version === "string" ? version : null);
|
|
573
|
+
};
|
|
574
|
+
worker.postMessage({ type: "pylon:version" }, [channel.port2]);
|
|
575
|
+
}),
|
|
576
|
+
// eslint-disable-next-line no-console
|
|
577
|
+
warn: (message) => console.warn(message),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The page's updater. `useAppUpdate()` reads it. */
|
|
582
|
+
export const appUpdate = new AppUpdater(browserEnv);
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Set the build this page runs. The SSR client runtime calls it through
|
|
586
|
+
* `registerOfflineWorker({ build })`; apps do not call it.
|
|
587
|
+
*/
|
|
588
|
+
export function configureAppUpdate(build: AppBuild | null, production: boolean): void {
|
|
589
|
+
appUpdate.configure(build, production);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** An updater with its own environment, for tests and for apps that
|
|
593
|
+
* render outside the SSR client runtime. */
|
|
594
|
+
export function createAppUpdate(options: {
|
|
595
|
+
build: AppBuild | null;
|
|
596
|
+
production: boolean;
|
|
597
|
+
env: AppUpdateEnv | null;
|
|
598
|
+
}): AppUpdater {
|
|
599
|
+
const updater = new AppUpdater(() => options.env);
|
|
600
|
+
updater.configure(options.build, options.production);
|
|
601
|
+
return updater;
|
|
602
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { compareForOrder, compileWhere } from "./queryFilter";
|
|
3
4
|
import { SyncEngine, generateId, pylonFetch, type Row } from "@pylonsync/sync";
|
|
4
5
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
6
|
import { callFn, clientTransport } from "./index";
|
|
@@ -476,7 +477,7 @@ function applyClientFilter(rows: Iterable<Row>, options?: QueryOptions): Row[] {
|
|
|
476
477
|
if (!matches || matches(row)) out.push(row);
|
|
477
478
|
}
|
|
478
479
|
for (const [field, dir] of order) {
|
|
479
|
-
out.sort((a, b) =>
|
|
480
|
+
out.sort((a, b) => compareForOrder(a[field], b[field], dir));
|
|
480
481
|
}
|
|
481
482
|
if (typeof limit === "number") {
|
|
482
483
|
return out.slice(0, limit);
|
|
@@ -484,73 +485,6 @@ function applyClientFilter(rows: Iterable<Row>, options?: QueryOptions): Row[] {
|
|
|
484
485
|
return out;
|
|
485
486
|
}
|
|
486
487
|
|
|
487
|
-
function compileWhere(where: QueryFilter): (row: Row) => boolean {
|
|
488
|
-
const fields = Object.entries(where)
|
|
489
|
-
.filter(([key]) => key !== "$order" && key !== "$limit")
|
|
490
|
-
.map(([key, val]) => ({
|
|
491
|
-
key,
|
|
492
|
-
val,
|
|
493
|
-
operators: val !== null && typeof val === "object" && !Array.isArray(val)
|
|
494
|
-
? Object.entries(val as Record<string, unknown>).map(([op, opVal]) => ({
|
|
495
|
-
op,
|
|
496
|
-
opVal,
|
|
497
|
-
members: op === "$in" && Array.isArray(opVal) ? new Set(opVal) : undefined,
|
|
498
|
-
}))
|
|
499
|
-
: undefined,
|
|
500
|
-
}));
|
|
501
|
-
return (row) => {
|
|
502
|
-
for (const { key, val, operators } of fields) {
|
|
503
|
-
const rowVal = row[key];
|
|
504
|
-
if (operators) {
|
|
505
|
-
for (const { op, opVal, members } of operators) {
|
|
506
|
-
switch (op) {
|
|
507
|
-
case "$not":
|
|
508
|
-
if (rowVal === opVal) return false;
|
|
509
|
-
break;
|
|
510
|
-
case "$gt":
|
|
511
|
-
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal > opVal))
|
|
512
|
-
return false;
|
|
513
|
-
break;
|
|
514
|
-
case "$gte":
|
|
515
|
-
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal >= opVal))
|
|
516
|
-
return false;
|
|
517
|
-
break;
|
|
518
|
-
case "$lt":
|
|
519
|
-
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal < opVal))
|
|
520
|
-
return false;
|
|
521
|
-
break;
|
|
522
|
-
case "$lte":
|
|
523
|
-
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal <= opVal))
|
|
524
|
-
return false;
|
|
525
|
-
break;
|
|
526
|
-
case "$like":
|
|
527
|
-
if (
|
|
528
|
-
!(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal))
|
|
529
|
-
)
|
|
530
|
-
return false;
|
|
531
|
-
break;
|
|
532
|
-
case "$in":
|
|
533
|
-
if (!members?.has(rowVal)) return false;
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
} else {
|
|
538
|
-
if (rowVal !== val) return false;
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
return true;
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function compare(a: unknown, b: unknown, dir: "asc" | "desc"): number {
|
|
546
|
-
const mult = dir === "desc" ? -1 : 1;
|
|
547
|
-
if (a === b) return 0;
|
|
548
|
-
if (a === undefined || a === null) return mult;
|
|
549
|
-
if (b === undefined || b === null) return -mult;
|
|
550
|
-
if (typeof a === "number" && typeof b === "number") return (a - b) * mult;
|
|
551
|
-
return String(a).localeCompare(String(b)) * mult;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
488
|
// ---------------------------------------------------------------------------
|
|
555
489
|
// useMutation — call a server-side TypeScript function
|
|
556
490
|
// ---------------------------------------------------------------------------
|