@netless/app-presentation 0.1.13-beta.2 → 0.1.13-beta.3

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.
@@ -36,6 +36,7 @@ import {
36
36
  isValidSize,
37
37
  } from "./camera-reference";
38
38
  import { createPresentationRuntimeTeardown } from "./runtime-lifecycle";
39
+ import { waitForCurrentPageImage } from "./setup-ready";
39
40
  import debounce from "lodash/debounce";
40
41
 
41
42
  export type Logger = (...data: any[]) => void;
@@ -199,44 +200,6 @@ const isBlurDegradationAllowed = (context: AppContext): boolean => {
199
200
 
200
201
  const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
201
202
 
202
- /**
203
- * Resolve once the current page background image has loaded (or after
204
- * `timeoutMs`). `false` means timeout or the app was disposed mid-wait;
205
- * callers must treat it as "continue loading in background", not an error.
206
- */
207
- const waitForCurrentPageImage = (
208
- view: View,
209
- timeoutMs: number,
210
- isDisposed: () => boolean,
211
- ): Promise<boolean> => {
212
- return new Promise<boolean>(resolve => {
213
- let settled = false;
214
- let pollTimer: number | undefined;
215
- const settle = (loaded: boolean) => {
216
- if (settled) return;
217
- settled = true;
218
- window.clearTimeout(timeoutTimer);
219
- if (pollTimer !== undefined) window.clearInterval(pollTimer);
220
- resolve(loaded);
221
- };
222
- const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
223
- const check = () => {
224
- if (isDisposed()) {
225
- settle(false);
226
- return;
227
- }
228
- const img = findBackgroundImage(view);
229
- if (img && img.complete && img.naturalWidth > 0) {
230
- settle(true);
231
- }
232
- };
233
- check();
234
- if (!settled) {
235
- pollTimer = window.setInterval(check, 100);
236
- }
237
- });
238
- };
239
-
240
203
  /**
241
204
  * Compare image URLs by resolved absolute href so relative/absolute
242
205
  * representations of the same URL do not look different (and to keep the
@@ -283,7 +246,8 @@ const hasUsableThumbnail = (page: PresentationPage): boolean => {
283
246
  * hidden and replaced with a static pixel after TeleBox commits the blur.
284
247
  *
285
248
  * While degraded, every load of a background `<img>` is re-degraded
286
- * immediately via a capture-phase `load` listener on the view container
249
+ * immediately via a capture-phase `load` listener on the document, filtered
250
+ * to the current view container
287
251
  * (`load` does not bubble but capture works). This covers remote page turns,
288
252
  * SDK React re-renders and the SDK's own background-image onError retries —
289
253
  * any path that (re)renders the full-size image while the app stays blurred.
@@ -1522,9 +1486,13 @@ export const NetlessAppPresentation: NetlessApp<
1522
1486
  return prepareScenesPromise.then(async () => {
1523
1487
  // Keep the serial setup queue honest: resolve only once the current
1524
1488
  // page image is actually visible (or the bounded wait gives up).
1525
- const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1526
- if (!loaded && !disposed) {
1489
+ const result = await waitForCurrentPageImage(
1490
+ () => findBackgroundImage(view), setupReadyTimeout, () => disposed,
1491
+ );
1492
+ if (result === "timeout" && !disposed) {
1527
1493
  warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1494
+ } else if (result === "error" && !disposed) {
1495
+ warn("[Presentation] setup page image failed to load; continuing setup");
1528
1496
  }
1529
1497
  return controller;
1530
1498
  }) as unknown as PresentationController;
package/src/preload.ts CHANGED
@@ -38,6 +38,7 @@ export class Preload implements IDisposable {
38
38
  // 暂停后不再创建预加载链接(app 失焦降级时调用);已插入的 link 会被移除,
39
39
  // 挂起的 idle 回调重新进入 touch() 时也会被跳过
40
40
  paused: boolean = false;
41
+ private disposed = false;
41
42
  constructor(readonly pages: PresentationPage[]) {
42
43
  this.preloadMap = new Map(pages.map((e,index) => [index, {
43
44
  src: e.src,
@@ -52,23 +53,23 @@ export class Preload implements IDisposable {
52
53
  this.destroySomeLink();
53
54
  }
54
55
  resume(index?: number) {
55
- if (!this.paused) return;
56
+ if (!this.paused || this.disposed) return;
56
57
  this.paused = false;
57
58
  this.touch(index ?? this.touchIndex, true);
58
59
  }
59
60
  touch(index: number, force: boolean = false) {
60
- if (this.paused) return;
61
+ if (this.paused || this.disposed) return;
61
62
  if (index >= this.preloadSize) {
62
63
  this.touchIndex = 0;
63
64
  } else {
64
65
  this.touchIndex = index;
65
66
  }
66
67
  const value = this.preloadMap.get(this.touchIndex);
67
- if (value && value.state === ELoadState.unloaded) {
68
+ if (value && (value.state === ELoadState.unloaded || (force && value.state === ELoadState.error))) {
68
69
  this.createLink(this.touchIndex, force);
69
70
  }
70
71
  if (this.loadingLinks.size < Preload.maxLinks) {
71
- const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state !== ELoadState.loaded);
72
+ const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state === ELoadState.unloaded);
72
73
  if (willLoad) {
73
74
  this.requestAsyncCallBack(()=>{this.touch(willLoad[0], false)}, 100);
74
75
  }
@@ -100,21 +101,25 @@ export class Preload implements IDisposable {
100
101
  linkDom.as = 'image';
101
102
  linkDom.href = value.src;
102
103
  linkDom.dataset.order = index + '';
103
- document.head.appendChild(linkDom);
104
104
  linkDom.onload = () => {
105
+ if (this.loadingLinks.get(index)?.link !== linkDom) return;
105
106
  const value = this.preloadMap.get(index);
106
107
  if (value) {
107
108
  value.state = ELoadState.loaded;
108
109
  this.preloadMap.set(index, value);
109
- document.head.contains(linkDom) && document.head.removeChild(linkDom);
110
- this.loadingLinks.delete(index);
110
+ this.removeLink(index, linkDom);
111
111
  this.touch(this.touchIndex + 1, false);
112
112
  }
113
113
  }
114
114
  linkDom.onerror = () => {
115
+ if (this.loadingLinks.get(index)?.link !== linkDom) return;
115
116
  const value = this.preloadMap.get(index);
116
- if (value?.src) {
117
- linkDom.href = value.src;
117
+ if (value) {
118
+ // Preloading is best effort. Do not retry in an error loop;
119
+ // revisiting the page explicitly may retry after network recovery.
120
+ value.state = ELoadState.error;
121
+ this.removeLink(index, linkDom);
122
+ this.touch(this.touchIndex + 1, false);
118
123
  }
119
124
  }
120
125
  this.loadingLinks.set(index, {
@@ -123,18 +128,24 @@ export class Preload implements IDisposable {
123
128
  });
124
129
  value.state = ELoadState.unloaded;
125
130
  this.preloadMap.set(index, value);
131
+ document.head.appendChild(linkDom);
126
132
  return ELoadState.unloaded;
127
133
  }
128
134
  return ELoadState.error;
129
135
  }
130
136
 
137
+ private removeLink(index: number, link: HTMLLinkElement) {
138
+ link.onload = null;
139
+ link.onerror = null;
140
+ document.head.contains(link) && document.head.removeChild(link);
141
+ this.loadingLinks.delete(index);
142
+ }
131
143
  private destroySomeLink(excludeIndex?: number) {
132
144
  for (const [index, { link }] of this.loadingLinks) {
133
145
  if (excludeIndex !== undefined && index === excludeIndex) {
134
146
  continue;
135
147
  }
136
- document.head.contains(link) && document.head.removeChild(link);
137
- this.loadingLinks.delete(index);
148
+ this.removeLink(index, link);
138
149
  }
139
150
  }
140
151
  private async requestAsyncCallBack (callBack:()=>void, timeout:number):Promise<void> {
@@ -152,6 +163,7 @@ export class Preload implements IDisposable {
152
163
  callBack();
153
164
  }
154
165
  dispose() {
166
+ this.disposed = true;
155
167
  this.destroySomeLink();
156
168
  this.preloadMap.clear();
157
169
  }
@@ -1,7 +1,7 @@
1
1
  export interface PresentationRuntimeBox {
2
- unmountContent(): unknown;
3
- unmountFooter(): unknown;
4
- unmountStyles(): unknown;
2
+ unmountContent?(): unknown;
3
+ unmountFooter?(): unknown;
4
+ unmountStyles?(): unknown;
5
5
  }
6
6
 
7
7
  /** Build an idempotent runtime teardown while preserving the TeleBox shell. */
@@ -16,9 +16,16 @@ export const createPresentationRuntimeTeardown = (
16
16
  try {
17
17
  dispose();
18
18
  } finally {
19
- box.unmountContent();
20
- box.unmountFooter();
21
- box.unmountStyles();
19
+ // A partial host API or a failing mount must not skip other cleanup.
20
+ try {
21
+ box.unmountContent?.();
22
+ } finally {
23
+ try {
24
+ box.unmountFooter?.();
25
+ } finally {
26
+ box.unmountStyles?.();
27
+ }
28
+ }
22
29
  }
23
30
  };
24
31
  };
@@ -0,0 +1,39 @@
1
+ export type SetupImageResult = "loaded" | "error" | "timeout" | "disposed";
2
+
3
+ /** Wait for a terminal image state without blocking setup indefinitely. */
4
+ export const waitForCurrentPageImage = (
5
+ getImage: () => HTMLImageElement | null,
6
+ timeoutMs: number,
7
+ isDisposed: () => boolean,
8
+ ): Promise<SetupImageResult> => {
9
+ return new Promise(resolve => {
10
+ let settled = false;
11
+ let pollTimer: number | undefined;
12
+ const settle = (result: SetupImageResult) => {
13
+ if (settled) return;
14
+ settled = true;
15
+ window.clearTimeout(timeoutTimer);
16
+ if (pollTimer !== undefined) window.clearInterval(pollTimer);
17
+ resolve(result);
18
+ };
19
+ const timeoutTimer = window.setTimeout(() => settle("timeout"), timeoutMs);
20
+ const check = () => {
21
+ if (isDisposed()) {
22
+ settle("disposed");
23
+ return;
24
+ }
25
+ const img = getImage();
26
+ if (img?.complete) {
27
+ if (img.naturalWidth > 0) {
28
+ settle("loaded");
29
+ } else if (img.currentSrc || img.getAttribute("src")?.trim() || img.getAttribute("srcset")?.trim()) {
30
+ // complete is also true before src is assigned. Only an actual
31
+ // image request with zero decoded width represents a failed load.
32
+ settle("error");
33
+ }
34
+ }
35
+ };
36
+ check();
37
+ if (!settled) pollTimer = window.setInterval(check, 100);
38
+ });
39
+ };