@pylonsync/react 0.22.60 → 0.22.61
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/appUpdate.d.ts +41 -1
- package/dist/index.d.ts +2 -2
- package/package.json +4 -4
- package/src/appUpdate.test.ts +112 -1
- package/src/appUpdate.ts +84 -11
- package/src/dynamic.test.tsx +79 -0
- package/src/dynamic.tsx +41 -6
- package/src/index.ts +8 -2
package/dist/appUpdate.d.ts
CHANGED
|
@@ -8,6 +8,29 @@ export declare const WORKER_WAIT_MS: number;
|
|
|
8
8
|
/** A reload that lands on the same build within this window does not
|
|
9
9
|
* reload again. */
|
|
10
10
|
export declare const RELOAD_GUARD_MS: number;
|
|
11
|
+
/** What `moveToLatestBuild()` did. */
|
|
12
|
+
export type BuildMove =
|
|
13
|
+
/** The page is loading the server's build. */
|
|
14
|
+
"moving"
|
|
15
|
+
/** The server runs this page's build. */
|
|
16
|
+
| "current"
|
|
17
|
+
/** The server did not answer. */
|
|
18
|
+
| "offline"
|
|
19
|
+
/** Writes wait to be sent, and the caller asked not to wait for them. */
|
|
20
|
+
| "pending-writes"
|
|
21
|
+
/** The page reloaded from this build moments ago and still runs it. */
|
|
22
|
+
| "blocked"
|
|
23
|
+
/** No build to compare: `pylon dev`, SSR, or an app without `offline`. */
|
|
24
|
+
| "inert";
|
|
25
|
+
export interface MoveOptions {
|
|
26
|
+
/** Load this URL in the new build. Default: reload the current URL. */
|
|
27
|
+
url?: string;
|
|
28
|
+
/** Ask the server for its build first, because a file failed to load.
|
|
29
|
+
* Default: use what the last check found. */
|
|
30
|
+
recheck?: boolean;
|
|
31
|
+
/** Return `"pending-writes"` instead of waiting for pending writes. */
|
|
32
|
+
skipIfPendingWrites?: boolean;
|
|
33
|
+
}
|
|
11
34
|
/** One build of the app. */
|
|
12
35
|
export interface AppBuild {
|
|
13
36
|
/** The service worker version: a hash of the build. */
|
|
@@ -83,7 +106,8 @@ export interface AppUpdateEnv {
|
|
|
83
106
|
setTimeout(fn: () => void, ms: number): unknown;
|
|
84
107
|
clearTimeout(handle: unknown): void;
|
|
85
108
|
now(): number;
|
|
86
|
-
|
|
109
|
+
/** Reload the page, or load `url` when given. */
|
|
110
|
+
reload(url?: string): void;
|
|
87
111
|
sessionStorage: StorageLike | null;
|
|
88
112
|
localStorage: StorageLike | null;
|
|
89
113
|
/** The page runs as an iOS home-screen app (`navigator.standalone`). */
|
|
@@ -131,6 +155,15 @@ export declare class AppUpdater {
|
|
|
131
155
|
* own cannot reload in a loop).
|
|
132
156
|
*/
|
|
133
157
|
apply: () => Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Move to the server's build when it is not this page's: `apply()`, then
|
|
160
|
+
* load `url` (or reload). Resolves with what it did; on `"moving"` the
|
|
161
|
+
* page is about to unload. Never moves while the server cannot be reached,
|
|
162
|
+
* so an offline page keeps its screen.
|
|
163
|
+
*/
|
|
164
|
+
moveToLatestBuild: (options?: MoveOptions) => Promise<BuildMove>;
|
|
165
|
+
/** Run `apply()` once at a time; resolves with whether the page reloads. */
|
|
166
|
+
private startApply;
|
|
134
167
|
private activeEnv;
|
|
135
168
|
private start;
|
|
136
169
|
private stop;
|
|
@@ -156,6 +189,13 @@ export declare const appUpdate: AppUpdater;
|
|
|
156
189
|
* `registerOfflineWorker({ build })`; apps do not call it.
|
|
157
190
|
*/
|
|
158
191
|
export declare function configureAppUpdate(build: AppBuild | null, production: boolean): void;
|
|
192
|
+
/**
|
|
193
|
+
* Move this page to the server's build when it runs another one. The SSR
|
|
194
|
+
* client runtime calls it on navigation and when a route entry fails to
|
|
195
|
+
* load; `dynamic()` calls it when a chunk fails to load. See
|
|
196
|
+
* `AppUpdater.moveToLatestBuild`.
|
|
197
|
+
*/
|
|
198
|
+
export declare function moveToLatestBuild(options?: MoveOptions): Promise<BuildMove>;
|
|
159
199
|
/** An updater with its own environment, for tests and for apps that
|
|
160
200
|
* render outside the SSR client runtime. */
|
|
161
201
|
export declare function createAppUpdate(options: {
|
package/dist/index.d.ts
CHANGED
|
@@ -45,8 +45,8 @@ export { useAppUpdate } from "./useAppUpdate";
|
|
|
45
45
|
export type { UseAppUpdateReturn } from "./useAppUpdate";
|
|
46
46
|
export { AppUpdateBar } from "./AppUpdateBar";
|
|
47
47
|
export type { AppUpdateBarProps } from "./AppUpdateBar";
|
|
48
|
-
export { createAppUpdate, AppUpdater } from "./appUpdate";
|
|
49
|
-
export type { AppBuild, AppUpdateState, AppUpdateEnv } from "./appUpdate";
|
|
48
|
+
export { createAppUpdate, AppUpdater, moveToLatestBuild } from "./appUpdate";
|
|
49
|
+
export type { AppBuild, AppUpdateState, AppUpdateEnv, BuildMove, MoveOptions, } from "./appUpdate";
|
|
50
50
|
export { db, init, getSync, setSessionToken } from "./db";
|
|
51
51
|
export { createTypedDb } from "./typed";
|
|
52
52
|
export type { TypedDb, AgentDBSchema } from "./typed";
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.22.
|
|
6
|
+
"version": "0.22.61",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/realtime": "0.22.
|
|
18
|
-
"@pylonsync/sdk": "0.22.
|
|
19
|
-
"@pylonsync/sync": "0.22.
|
|
17
|
+
"@pylonsync/realtime": "0.22.61",
|
|
18
|
+
"@pylonsync/sdk": "0.22.61",
|
|
19
|
+
"@pylonsync/sync": "0.22.61"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=19.0.0"
|
package/src/appUpdate.test.ts
CHANGED
|
@@ -99,6 +99,7 @@ function makeEnv(opts: {
|
|
|
99
99
|
const fetches: string[] = [];
|
|
100
100
|
const warnings: string[] = [];
|
|
101
101
|
let reloads = 0;
|
|
102
|
+
const reloadUrls: Array<string | undefined> = [];
|
|
102
103
|
const doc = {
|
|
103
104
|
visibilityState: "visible",
|
|
104
105
|
addEventListener: (_t: string, l: () => void) => docListeners.add(l),
|
|
@@ -138,8 +139,9 @@ function makeEnv(opts: {
|
|
|
138
139
|
if (i >= 0) timers.splice(i, 1);
|
|
139
140
|
},
|
|
140
141
|
now: () => now,
|
|
141
|
-
reload: () => {
|
|
142
|
+
reload: (url) => {
|
|
142
143
|
reloads++;
|
|
144
|
+
reloadUrls.push(url);
|
|
143
145
|
},
|
|
144
146
|
sessionStorage: opts.session ?? new MemoryStorage(),
|
|
145
147
|
localStorage: opts.local ?? new MemoryStorage(),
|
|
@@ -163,6 +165,7 @@ function makeEnv(opts: {
|
|
|
163
165
|
warnings,
|
|
164
166
|
intervals,
|
|
165
167
|
reloads: () => reloads,
|
|
168
|
+
reloadUrls,
|
|
166
169
|
setLatest: (l: typeof latest) => {
|
|
167
170
|
latest = l;
|
|
168
171
|
},
|
|
@@ -459,3 +462,111 @@ test("the first check does not record a newer build's install identity", async (
|
|
|
459
462
|
expect(local.map.size).toBe(0);
|
|
460
463
|
expect(u.getSnapshot().reinstallRecommended).toBe(false);
|
|
461
464
|
});
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// moveToLatestBuild: the client runtime moves an open page to a new deploy.
|
|
468
|
+
// Veritas, after a deploy: the new worker waited for every tab to close, and
|
|
469
|
+
// each click in an open tab became a full page load of the old build.
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
test("after a deploy, a click moves the page once: the waiting worker takes over and the link loads", async () => {
|
|
473
|
+
const reg = new FakeRegistration();
|
|
474
|
+
reg.waiting = new FakeWorker("v2");
|
|
475
|
+
const f = makeEnv({ latest: { version: "v2" }, registration: reg, pending: 0 });
|
|
476
|
+
const u = createAppUpdate({ build: BUILD, production: true, env: f.env });
|
|
477
|
+
await u.check();
|
|
478
|
+
|
|
479
|
+
const move = u.moveToLatestBuild({ url: "/leads", skipIfPendingWrites: true });
|
|
480
|
+
await settle();
|
|
481
|
+
expect(reg.waiting!.messages).toEqual([{ type: "pylon:skip-waiting" }]);
|
|
482
|
+
f.fireControllerChange();
|
|
483
|
+
expect(await move).toBe("moving");
|
|
484
|
+
expect(f.reloadUrls).toEqual(["/leads"]);
|
|
485
|
+
|
|
486
|
+
// More clicks before the page unloads: no second load.
|
|
487
|
+
expect(await u.moveToLatestBuild({ url: "/map" })).toBe("moving");
|
|
488
|
+
expect(f.reloads()).toBe(1);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a page that a move left on the old build does not move again", async () => {
|
|
492
|
+
const session = new MemoryStorage();
|
|
493
|
+
const first = makeEnv({ latest: { version: "v2" }, registration: null, session });
|
|
494
|
+
const u1 = createAppUpdate({ build: BUILD, production: true, env: first.env });
|
|
495
|
+
expect(await u1.moveToLatestBuild({ url: "/leads", recheck: true })).toBe("moving");
|
|
496
|
+
|
|
497
|
+
// The load came back on v1 (the server answered with the old build). The
|
|
498
|
+
// next click must not load again: that is the reload per click.
|
|
499
|
+
const second = makeEnv({ latest: { version: "v2" }, registration: null, session });
|
|
500
|
+
const u2 = createAppUpdate({ build: BUILD, production: true, env: second.env });
|
|
501
|
+
expect(await u2.moveToLatestBuild({ url: "/map", recheck: true })).toBe("blocked");
|
|
502
|
+
expect(await u2.moveToLatestBuild({ url: "/map", recheck: true })).toBe("blocked");
|
|
503
|
+
expect(second.reloads()).toBe(0);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a click with unsent writes does not wait for them; a failed file load does", async () => {
|
|
507
|
+
const reg = new FakeRegistration();
|
|
508
|
+
reg.waiting = new FakeWorker("v2");
|
|
509
|
+
const f = makeEnv({ latest: { version: "v2" }, registration: reg, pending: 1 });
|
|
510
|
+
const u = createAppUpdate({ build: BUILD, production: true, env: f.env });
|
|
511
|
+
await u.check();
|
|
512
|
+
|
|
513
|
+
expect(await u.moveToLatestBuild({ url: "/leads", skipIfPendingWrites: true })).toBe(
|
|
514
|
+
"pending-writes",
|
|
515
|
+
);
|
|
516
|
+
expect(reg.waiting!.messages).toEqual([]);
|
|
517
|
+
|
|
518
|
+
// The old build cannot show the next screen: wait for the writes, then move.
|
|
519
|
+
const move = u.moveToLatestBuild({ url: "/map", recheck: true });
|
|
520
|
+
await settle();
|
|
521
|
+
expect(u.getSnapshot().waitingForSync).toBe(true);
|
|
522
|
+
expect(reg.waiting!.messages).toEqual([]);
|
|
523
|
+
f.setPending(0);
|
|
524
|
+
await settle();
|
|
525
|
+
expect(reg.waiting!.messages).toEqual([{ type: "pylon:skip-waiting" }]);
|
|
526
|
+
f.fireControllerChange();
|
|
527
|
+
expect(await move).toBe("moving");
|
|
528
|
+
expect(f.reloadUrls).toEqual(["/map"]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a failed file load while offline keeps the page", async () => {
|
|
532
|
+
const reg = new FakeRegistration();
|
|
533
|
+
const f = makeEnv({ latest: { version: "v2" }, registration: reg });
|
|
534
|
+
const u = createAppUpdate({ build: BUILD, production: true, env: f.env });
|
|
535
|
+
await u.check();
|
|
536
|
+
f.setLatest("offline");
|
|
537
|
+
expect(await u.moveToLatestBuild({ recheck: true })).toBe("offline");
|
|
538
|
+
expect(f.reloads()).toBe(0);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("a page of the new build under the old worker lets its own worker take over", async () => {
|
|
542
|
+
// The tab loaded build v2 from the network, but the v1 worker still
|
|
543
|
+
// controls it and answers the build manifest from its cache.
|
|
544
|
+
const reg = new FakeRegistration();
|
|
545
|
+
reg.waiting = new FakeWorker("v2");
|
|
546
|
+
const f = makeEnv({ latest: { version: "v2" }, registration: reg });
|
|
547
|
+
const u = createAppUpdate({
|
|
548
|
+
build: { version: "v2", builtAt: null },
|
|
549
|
+
production: true,
|
|
550
|
+
env: f.env,
|
|
551
|
+
});
|
|
552
|
+
const move = u.moveToLatestBuild({ url: "/leads", recheck: true });
|
|
553
|
+
await settle();
|
|
554
|
+
expect(reg.waiting!.messages).toEqual([{ type: "pylon:skip-waiting" }]);
|
|
555
|
+
f.fireControllerChange();
|
|
556
|
+
expect(await move).toBe("moving");
|
|
557
|
+
expect(f.reloadUrls).toEqual(["/leads"]);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("a failed file load with the server on this build does not move", async () => {
|
|
561
|
+
const f = makeEnv({ latest: { version: "v1" }, registration: new FakeRegistration() });
|
|
562
|
+
const u = createAppUpdate({ build: BUILD, production: true, env: f.env });
|
|
563
|
+
expect(await u.moveToLatestBuild({ recheck: true })).toBe("current");
|
|
564
|
+
expect(f.reloads()).toBe(0);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("an app without offline does not move", async () => {
|
|
568
|
+
const f = makeEnv({ latest: { version: "v2" }, registration: null });
|
|
569
|
+
const u = createAppUpdate({ build: BUILD, production: false, env: f.env });
|
|
570
|
+
expect(await u.moveToLatestBuild({ recheck: true })).toBe("inert");
|
|
571
|
+
expect(f.fetches).toHaveLength(0);
|
|
572
|
+
});
|
package/src/appUpdate.ts
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
// - `apply()` waits until the outbox has no pending writes, tells the
|
|
20
20
|
// waiting worker to take over (`pylon:skip-waiting`), and reloads once
|
|
21
21
|
// when it does.
|
|
22
|
+
// - `moveToLatestBuild()` is the same move, started by the client runtime
|
|
23
|
+
// instead of the app: on a navigation when the server already runs a
|
|
24
|
+
// newer build, and when a file of this page's build failed to load (a
|
|
25
|
+
// route entry or a `dynamic()` chunk the deploy removed). It loads the
|
|
26
|
+
// link's URL in the new build. A failure while offline does not move.
|
|
22
27
|
// - On an iOS home-screen app, a change to the manifest's name, icons,
|
|
23
28
|
// display mode or start URL sets `reinstallRecommended`: iOS reads those
|
|
24
29
|
// only when the app is added.
|
|
@@ -52,6 +57,31 @@ const RELOAD_KEY = "pylon:app-update:reload";
|
|
|
52
57
|
/** localStorage: the install identity the home-screen app was added with. */
|
|
53
58
|
const INSTALL_KEY = "pylon:app-update:install";
|
|
54
59
|
|
|
60
|
+
/** What `moveToLatestBuild()` did. */
|
|
61
|
+
export type BuildMove =
|
|
62
|
+
/** The page is loading the server's build. */
|
|
63
|
+
| "moving"
|
|
64
|
+
/** The server runs this page's build. */
|
|
65
|
+
| "current"
|
|
66
|
+
/** The server did not answer. */
|
|
67
|
+
| "offline"
|
|
68
|
+
/** Writes wait to be sent, and the caller asked not to wait for them. */
|
|
69
|
+
| "pending-writes"
|
|
70
|
+
/** The page reloaded from this build moments ago and still runs it. */
|
|
71
|
+
| "blocked"
|
|
72
|
+
/** No build to compare: `pylon dev`, SSR, or an app without `offline`. */
|
|
73
|
+
| "inert";
|
|
74
|
+
|
|
75
|
+
export interface MoveOptions {
|
|
76
|
+
/** Load this URL in the new build. Default: reload the current URL. */
|
|
77
|
+
url?: string;
|
|
78
|
+
/** Ask the server for its build first, because a file failed to load.
|
|
79
|
+
* Default: use what the last check found. */
|
|
80
|
+
recheck?: boolean;
|
|
81
|
+
/** Return `"pending-writes"` instead of waiting for pending writes. */
|
|
82
|
+
skipIfPendingWrites?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
/** One build of the app. */
|
|
56
86
|
export interface AppBuild {
|
|
57
87
|
/** The service worker version: a hash of the build. */
|
|
@@ -131,7 +161,8 @@ export interface AppUpdateEnv {
|
|
|
131
161
|
setTimeout(fn: () => void, ms: number): unknown;
|
|
132
162
|
clearTimeout(handle: unknown): void;
|
|
133
163
|
now(): number;
|
|
134
|
-
|
|
164
|
+
/** Reload the page, or load `url` when given. */
|
|
165
|
+
reload(url?: string): void;
|
|
135
166
|
sessionStorage: StorageLike | null;
|
|
136
167
|
localStorage: StorageLike | null;
|
|
137
168
|
/** The page runs as an iOS home-screen app (`navigator.standalone`). */
|
|
@@ -200,7 +231,7 @@ export class AppUpdater {
|
|
|
200
231
|
private running = false;
|
|
201
232
|
private interval: unknown = null;
|
|
202
233
|
private checking: Promise<void> | null = null;
|
|
203
|
-
private applyRun: Promise<
|
|
234
|
+
private applyRun: Promise<boolean> | null = null;
|
|
204
235
|
private reloading = false;
|
|
205
236
|
|
|
206
237
|
constructor(envFactory: () => AppUpdateEnv | null) {
|
|
@@ -246,13 +277,44 @@ export class AppUpdater {
|
|
|
246
277
|
* that did not change the build (so a page that calls `apply()` on its
|
|
247
278
|
* own cannot reload in a loop).
|
|
248
279
|
*/
|
|
249
|
-
apply = (): Promise<void> => {
|
|
280
|
+
apply = (): Promise<void> => this.startApply().then(() => {});
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Move to the server's build when it is not this page's: `apply()`, then
|
|
284
|
+
* load `url` (or reload). Resolves with what it did; on `"moving"` the
|
|
285
|
+
* page is about to unload. Never moves while the server cannot be reached,
|
|
286
|
+
* so an offline page keeps its screen.
|
|
287
|
+
*/
|
|
288
|
+
moveToLatestBuild = async (options: MoveOptions = {}): Promise<BuildMove> => {
|
|
250
289
|
const env = this.activeEnv();
|
|
251
|
-
if (!env) return
|
|
290
|
+
if (!env) return "inert";
|
|
291
|
+
if (this.reloading) return "moving";
|
|
292
|
+
if (options.recheck) {
|
|
293
|
+
this.latest = null;
|
|
294
|
+
await this.check();
|
|
295
|
+
}
|
|
296
|
+
const latest = this.latest?.version ?? null;
|
|
297
|
+
if (latest === null) return "offline";
|
|
298
|
+
// The server runs this page's build, but its worker still waits behind
|
|
299
|
+
// an older one, which answers with the older build's cached files (the
|
|
300
|
+
// build manifest among them, from a worker of an earlier Pylon). Let it take
|
|
301
|
+
// over.
|
|
302
|
+
const ownWorkerWaits = this.waitingVersion === this.build!.version;
|
|
303
|
+
if (latest === this.build!.version && !ownWorkerWaits) return "current";
|
|
304
|
+
if (options.skipIfPendingWrites && (env.pendingWrites()?.count() ?? 0) > 0) {
|
|
305
|
+
return "pending-writes";
|
|
306
|
+
}
|
|
307
|
+
return (await this.startApply(options.url)) ? "moving" : "blocked";
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** Run `apply()` once at a time; resolves with whether the page reloads. */
|
|
311
|
+
private startApply(url?: string): Promise<boolean> {
|
|
312
|
+
const env = this.activeEnv();
|
|
313
|
+
if (!env) return Promise.resolve(false);
|
|
252
314
|
if (!this.applyRun) {
|
|
253
315
|
this.applying = true;
|
|
254
316
|
this.emit();
|
|
255
|
-
this.applyRun = this.runApply(env).finally(() => {
|
|
317
|
+
this.applyRun = this.runApply(env, url).finally(() => {
|
|
256
318
|
this.applyRun = null;
|
|
257
319
|
this.applying = false;
|
|
258
320
|
this.waitingForSync = false;
|
|
@@ -415,7 +477,7 @@ export class AppUpdater {
|
|
|
415
477
|
this.reinstall = stored.install !== latest.install;
|
|
416
478
|
}
|
|
417
479
|
|
|
418
|
-
private async runApply(env: AppUpdateEnv): Promise<
|
|
480
|
+
private async runApply(env: AppUpdateEnv, url?: string): Promise<boolean> {
|
|
419
481
|
const from = this.build!.version;
|
|
420
482
|
const last = readJson(env.sessionStorage, RELOAD_KEY);
|
|
421
483
|
if (
|
|
@@ -428,7 +490,7 @@ export class AppUpdater {
|
|
|
428
490
|
`[pylon] apply() reloaded ${Math.round((env.now() - last.at) / 1000)}s ago ` +
|
|
429
491
|
`and the page still runs build ${from}. Not reloading again.`,
|
|
430
492
|
);
|
|
431
|
-
return;
|
|
493
|
+
return false;
|
|
432
494
|
}
|
|
433
495
|
|
|
434
496
|
await this.drainOutbox(env);
|
|
@@ -468,13 +530,14 @@ export class AppUpdater {
|
|
|
468
530
|
} else if (waiting) {
|
|
469
531
|
waiting.postMessage({ type: "pylon:skip-waiting" });
|
|
470
532
|
}
|
|
471
|
-
this.reload(env);
|
|
533
|
+
this.reload(env, url);
|
|
534
|
+
return true;
|
|
472
535
|
}
|
|
473
536
|
|
|
474
|
-
private reload(env: AppUpdateEnv): void {
|
|
537
|
+
private reload(env: AppUpdateEnv, url?: string): void {
|
|
475
538
|
if (this.reloading) return;
|
|
476
539
|
this.reloading = true;
|
|
477
|
-
env.reload();
|
|
540
|
+
env.reload(url);
|
|
478
541
|
}
|
|
479
542
|
|
|
480
543
|
private drainOutbox(env: AppUpdateEnv): Promise<void> {
|
|
@@ -545,7 +608,7 @@ export function browserEnv(): AppUpdateEnv | null {
|
|
|
545
608
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
546
609
|
clearTimeout: (h) => clearTimeout(h as any),
|
|
547
610
|
now: () => Date.now(),
|
|
548
|
-
reload: () => window.location.reload(),
|
|
611
|
+
reload: (url) => (url ? window.location.assign(url) : window.location.reload()),
|
|
549
612
|
sessionStorage: safeStorage(() => window.sessionStorage),
|
|
550
613
|
localStorage: safeStorage(() => window.localStorage),
|
|
551
614
|
iosStandalone: nav.standalone === true,
|
|
@@ -589,6 +652,16 @@ export function configureAppUpdate(build: AppBuild | null, production: boolean):
|
|
|
589
652
|
appUpdate.configure(build, production);
|
|
590
653
|
}
|
|
591
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Move this page to the server's build when it runs another one. The SSR
|
|
657
|
+
* client runtime calls it on navigation and when a route entry fails to
|
|
658
|
+
* load; `dynamic()` calls it when a chunk fails to load. See
|
|
659
|
+
* `AppUpdater.moveToLatestBuild`.
|
|
660
|
+
*/
|
|
661
|
+
export function moveToLatestBuild(options?: MoveOptions): Promise<BuildMove> {
|
|
662
|
+
return appUpdate.moveToLatestBuild(options);
|
|
663
|
+
}
|
|
664
|
+
|
|
592
665
|
/** An updater with its own environment, for tests and for apps that
|
|
593
666
|
* render outside the SSR client runtime. */
|
|
594
667
|
export function createAppUpdate(options: {
|
package/src/dynamic.test.tsx
CHANGED
|
@@ -15,6 +15,25 @@ const { renderToStaticMarkup } = require("react-dom/server") as {
|
|
|
15
15
|
};
|
|
16
16
|
import React from "react";
|
|
17
17
|
import { dynamic } from "./dynamic";
|
|
18
|
+
import { appUpdate, type BuildMove, type MoveOptions } from "./appUpdate";
|
|
19
|
+
|
|
20
|
+
/** Answer the page's build check with `result`; records each call. */
|
|
21
|
+
function stubMove(result: BuildMove) {
|
|
22
|
+
const calls: MoveOptions[] = [];
|
|
23
|
+
const original = appUpdate.moveToLatestBuild;
|
|
24
|
+
appUpdate.moveToLatestBuild = async (options: MoveOptions = {}) => {
|
|
25
|
+
calls.push(options);
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
return { calls, restore: () => (appUpdate.moveToLatestBuild = original) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function quietErrors() {
|
|
32
|
+
const orig = console.error;
|
|
33
|
+
const seen: unknown[][] = [];
|
|
34
|
+
console.error = (...args: unknown[]) => seen.push(args);
|
|
35
|
+
return { seen, restore: () => (console.error = orig) };
|
|
36
|
+
}
|
|
18
37
|
|
|
19
38
|
// @testing-library auto-registers cleanup when it finds a global afterEach;
|
|
20
39
|
// bun:test doesn't provide one, so mounted trees would accumulate across tests
|
|
@@ -96,6 +115,66 @@ describe("dynamic() with ssr:false", () => {
|
|
|
96
115
|
});
|
|
97
116
|
});
|
|
98
117
|
|
|
118
|
+
describe("dynamic() after a deploy", () => {
|
|
119
|
+
// Veritas: the map's chunk 404'd after a deploy, and the page went blank.
|
|
120
|
+
test("a chunk the deploy removed moves the page to the new build", async () => {
|
|
121
|
+
const move = stubMove("moving");
|
|
122
|
+
const errors = quietErrors();
|
|
123
|
+
try {
|
|
124
|
+
const D = dynamic(async () => {
|
|
125
|
+
throw new TypeError("Failed to fetch dynamically imported module: /_pylon/build/chunks/map-loader-jk6q22q6.js");
|
|
126
|
+
}, { loading: Skeleton });
|
|
127
|
+
render(<D />);
|
|
128
|
+
await waitFor(() => expect(move.calls).toEqual([{ recheck: true }]));
|
|
129
|
+
expect(screen.getByTestId("skeleton")).toBeDefined();
|
|
130
|
+
// The page is loading the new build: nothing to report.
|
|
131
|
+
expect(errors.seen).toEqual([]);
|
|
132
|
+
} finally {
|
|
133
|
+
move.restore();
|
|
134
|
+
errors.restore();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("offline, the fallback stays and the next mount tries again", async () => {
|
|
139
|
+
const move = stubMove("offline");
|
|
140
|
+
const errors = quietErrors();
|
|
141
|
+
try {
|
|
142
|
+
let online = false;
|
|
143
|
+
const D = dynamic(async () => {
|
|
144
|
+
if (!online) throw new TypeError("Failed to fetch");
|
|
145
|
+
return { default: Heavy };
|
|
146
|
+
}, { loading: Skeleton });
|
|
147
|
+
const first = render(<D />);
|
|
148
|
+
await waitFor(() => expect(errors.seen).toHaveLength(1));
|
|
149
|
+
expect(screen.getByTestId("skeleton")).toBeDefined();
|
|
150
|
+
first.unmount();
|
|
151
|
+
online = true;
|
|
152
|
+
render(<D />);
|
|
153
|
+
await waitFor(() => expect(screen.getByTestId("heavy")).toBeDefined());
|
|
154
|
+
} finally {
|
|
155
|
+
move.restore();
|
|
156
|
+
errors.restore();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("with ssr:true, a removed chunk moves the page instead of throwing", async () => {
|
|
161
|
+
const move = stubMove("moving");
|
|
162
|
+
const errors = quietErrors();
|
|
163
|
+
try {
|
|
164
|
+
const D = dynamic(async () => {
|
|
165
|
+
throw new TypeError("Failed to fetch dynamically imported module");
|
|
166
|
+
}, { ssr: true, loading: Skeleton });
|
|
167
|
+
render(<D />);
|
|
168
|
+
await waitFor(() => expect(move.calls).toHaveLength(1));
|
|
169
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
170
|
+
expect(screen.getByTestId("skeleton")).toBeDefined();
|
|
171
|
+
} finally {
|
|
172
|
+
move.restore();
|
|
173
|
+
errors.restore();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
99
178
|
describe("dynamic() with ssr:true", () => {
|
|
100
179
|
test("server-renders the real component", async () => {
|
|
101
180
|
const D = dynamic(async () => ({ default: Heavy }), {
|
package/src/dynamic.tsx
CHANGED
|
@@ -26,6 +26,7 @@ import React, {
|
|
|
26
26
|
useState,
|
|
27
27
|
type ComponentType,
|
|
28
28
|
} from "react";
|
|
29
|
+
import { appUpdate } from "./appUpdate";
|
|
29
30
|
|
|
30
31
|
export interface DynamicOptions {
|
|
31
32
|
/** Rendered while the chunk loads (and, when `ssr` is false, on the server). */
|
|
@@ -47,6 +48,21 @@ export interface DynamicOptions {
|
|
|
47
48
|
type Loader<P> = () => Promise<{ default: ComponentType<P> } | ComponentType<P>>;
|
|
48
49
|
|
|
49
50
|
/** A module namespace or the component itself — accept both, like Next. */
|
|
51
|
+
/**
|
|
52
|
+
* A chunk failed to load. After a deploy the server no longer has this
|
|
53
|
+
* build's chunks: move the page to the new build (an app with `offline`
|
|
54
|
+
* only; see `moveToLatestBuild`). Resolves true when the page is about to
|
|
55
|
+
* load the new build. Offline, or with the server on this build, the page
|
|
56
|
+
* stays as it is.
|
|
57
|
+
*/
|
|
58
|
+
async function movedAfterChunkError(): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
return (await appUpdate.moveToLatestBuild({ recheck: true })) === "moving";
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
function resolveComponent<P>(
|
|
51
67
|
mod: { default: ComponentType<P> } | ComponentType<P>,
|
|
52
68
|
): ComponentType<P> {
|
|
@@ -78,7 +94,15 @@ export function dynamic<P extends object>(
|
|
|
78
94
|
// saving — the component is in the tree the server rendered — so this is
|
|
79
95
|
// for splitting code, not weight.
|
|
80
96
|
const Lazy = lazy(async () => {
|
|
81
|
-
|
|
97
|
+
let mod: Awaited<ReturnType<Loader<P>>>;
|
|
98
|
+
try {
|
|
99
|
+
mod = await loader();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// The page is loading the new build: keep the fallback up until it
|
|
102
|
+
// unloads, rather than handing the error to an error boundary.
|
|
103
|
+
if (await movedAfterChunkError()) return new Promise<never>(() => {});
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
82
106
|
return { default: resolveComponent<P>(mod) as ComponentType<any> };
|
|
83
107
|
});
|
|
84
108
|
const WithSuspense = (props: P) => (
|
|
@@ -95,9 +119,17 @@ export function dynamic<P extends object>(
|
|
|
95
119
|
// real component swaps in from an effect, which never runs during SSR.
|
|
96
120
|
//
|
|
97
121
|
// The promise is cached on first use so remounts don't refetch, and so two
|
|
98
|
-
// instances of the same dynamic component share one request.
|
|
122
|
+
// instances of the same dynamic component share one request. A failed load
|
|
123
|
+
// is not kept: the next mount tries again (offline, then back online).
|
|
99
124
|
let cached: Promise<ComponentType<P>> | null = null;
|
|
100
|
-
const load = () =>
|
|
125
|
+
const load = () =>
|
|
126
|
+
(cached ??= Promise.resolve()
|
|
127
|
+
.then(loader)
|
|
128
|
+
.then(resolveComponent<P>)
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
cached = null;
|
|
131
|
+
throw err;
|
|
132
|
+
}));
|
|
101
133
|
|
|
102
134
|
const ClientOnly = (props: P) => {
|
|
103
135
|
const [Loaded, setLoaded] = useState<ComponentType<P> | null>(null);
|
|
@@ -110,9 +142,12 @@ export function dynamic<P extends object>(
|
|
|
110
142
|
if (alive) setLoaded(() => C);
|
|
111
143
|
},
|
|
112
144
|
(err) => {
|
|
113
|
-
// A chunk that fails to load
|
|
114
|
-
// the
|
|
115
|
-
|
|
145
|
+
// A chunk that fails to load leaves the fallback up rather than
|
|
146
|
+
// blanking the subtree. After a deploy the page moves to the new
|
|
147
|
+
// build; offline it keeps the fallback.
|
|
148
|
+
void movedAfterChunkError().then((moving) => {
|
|
149
|
+
if (!moving) console.error("[pylon] dynamic() failed to load a component:", err);
|
|
150
|
+
});
|
|
116
151
|
},
|
|
117
152
|
);
|
|
118
153
|
return () => {
|
package/src/index.ts
CHANGED
|
@@ -212,8 +212,14 @@ export { useAppUpdate } from "./useAppUpdate";
|
|
|
212
212
|
export type { UseAppUpdateReturn } from "./useAppUpdate";
|
|
213
213
|
export { AppUpdateBar } from "./AppUpdateBar";
|
|
214
214
|
export type { AppUpdateBarProps } from "./AppUpdateBar";
|
|
215
|
-
export { createAppUpdate, AppUpdater } from "./appUpdate";
|
|
216
|
-
export type {
|
|
215
|
+
export { createAppUpdate, AppUpdater, moveToLatestBuild } from "./appUpdate";
|
|
216
|
+
export type {
|
|
217
|
+
AppBuild,
|
|
218
|
+
AppUpdateState,
|
|
219
|
+
AppUpdateEnv,
|
|
220
|
+
BuildMove,
|
|
221
|
+
MoveOptions,
|
|
222
|
+
} from "./appUpdate";
|
|
217
223
|
|
|
218
224
|
// One-liner API
|
|
219
225
|
export { db, init, getSync, setSessionToken } from "./db";
|