@pylonsync/react 0.22.59 → 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.
@@ -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
- reload(): void;
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.59",
6
+ "version": "0.22.61",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,15 +14,15 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/realtime": "0.22.59",
18
- "@pylonsync/sdk": "0.22.59",
19
- "@pylonsync/sync": "0.22.59"
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"
23
23
  },
24
24
  "devDependencies": {
25
- "@happy-dom/global-registrator": "^20.10.0",
25
+ "@happy-dom/global-registrator": "^21.0.4",
26
26
  "@testing-library/react": "^16.3.0",
27
27
  "@types/react": "^19.0.0",
28
28
  "@types/react-dom": "^19.0.0",
@@ -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
- reload(): void;
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<void> | null = null;
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 Promise.resolve();
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<void> {
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: {
@@ -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
- const mod = await loader();
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 = () => (cached ??= Promise.resolve(loader()).then(resolveComponent<P>));
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 (deploy mid-session, offline) leaves
114
- // the fallback up rather than blanking the subtree.
115
- console.error("[pylon] dynamic() failed to load a component:", err);
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 { AppBuild, AppUpdateState, AppUpdateEnv } from "./appUpdate";
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";