@netless/app-slide 0.2.106-beta.2 → 0.2.106-beta.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netless/app-slide",
3
- "version": "0.2.106-beta.2",
3
+ "version": "0.2.106-beta.4",
4
4
  "main": "dist/main.cjs.js",
5
5
  "module": "dist/main.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "build": "vite build && npm run types",
10
10
  "build:dev": "vite build --mode development && npm run types",
11
11
  "cleanup": "rimraf ./dist",
12
- "test": "rimraf test/.cache && esbuild test/ResizableContainer.test.ts test/NavigationButtonMode.test.ts test/DocsViewerNavigation.integration.test.ts --bundle --platform=node --format=cjs --outdir=test/.cache && node test/.cache/ResizableContainer.test.js && node test/.cache/NavigationButtonMode.test.js && node test/.cache/DocsViewerNavigation.integration.test.js"
12
+ "test": "rimraf test/.cache && esbuild test/ResizableContainer.test.ts test/NavigationButtonMode.test.ts test/DocsViewerNavigation.integration.test.ts test/FocusTransition.test.ts --bundle --platform=node --format=cjs --outdir=test/.cache && node test/.cache/ResizableContainer.test.js && node test/.cache/NavigationButtonMode.test.js && node test/.cache/DocsViewerNavigation.integration.test.js && node test/.cache/FocusTransition.test.js && node test/run-runtime-activity.mjs"
13
13
  },
14
14
  "files": [
15
15
  "src",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "devDependencies": {
20
20
  "@juggle/resize-observer": "^3.3.1",
21
- "@netless/slide": "1.4.61",
21
+ "@netless/slide": "1.4.62-beta.0",
22
22
  "@types/color-string": "^1.5.2",
23
23
  "color-string": "^1.9.1",
24
24
  "jspdf": "2.5.1",
@@ -17,8 +17,9 @@ import { SideEffectManager } from "side-effect-manager";
17
17
  import { Slide, SLIDE_EVENTS } from "@netless/slide";
18
18
  import { clamp } from "../utils/helpers";
19
19
  import { cachedGetBgColor } from "../utils/bgcolor";
20
- import { log, verbose, setRoomLogger } from "../utils/logger";
20
+ import { log, verbose, setRoomLogger, logger } from "../utils/logger";
21
21
  import { getRoomTracker } from "../utils/tracker";
22
+ import { createFocusTransitionQueue, shouldSlideRuntimeBeActive } from "../utils/focus-transition";
22
23
  export { syncSceneWithSlide, createDocsViewerPages } from "./helpers";
23
24
 
24
25
  export const DefaultUrl = "https://convertcdn.netless.link/dynamicConvert";
@@ -55,6 +56,48 @@ const noop = function noop() {
55
56
  // do nothing
56
57
  };
57
58
 
59
+ type SlideWebGLTask = {
60
+ label: string;
61
+ run: () => void | Promise<void>;
62
+ resolve: () => void;
63
+ reject: (error: unknown) => void;
64
+ };
65
+
66
+ const slideWebGLTasks: SlideWebGLTask[] = [];
67
+ let slideWebGLRunning = false;
68
+
69
+ function runNextSlideWebGLTask(): void {
70
+ if (slideWebGLRunning) return;
71
+ const task = slideWebGLTasks.shift();
72
+ if (!task) return;
73
+ slideWebGLRunning = true;
74
+ const startedAt = performance.now();
75
+ log("[Slide][player-queue] start", task.label, "pending", slideWebGLTasks.length);
76
+ void Promise.resolve().then(task.run).then(() => {
77
+ log("[Slide][player-queue] done", task.label, "ms", Math.round(performance.now() - startedAt));
78
+ task.resolve();
79
+ }, error => {
80
+ log("[Slide][player-queue] failed", task.label, error);
81
+ task.reject(error);
82
+ }).finally(() => {
83
+ slideWebGLRunning = false;
84
+ runNextSlideWebGLTask();
85
+ });
86
+ }
87
+
88
+ export function enqueueSlideWebGLTransition(
89
+ run: () => void | Promise<void>,
90
+ priority = false,
91
+ label = "unspecified"
92
+ ): Promise<void> {
93
+ return new Promise<void>((resolve, reject) => {
94
+ const task = { label, run, resolve, reject };
95
+ if (priority) slideWebGLTasks.unshift(task);
96
+ else slideWebGLTasks.push(task);
97
+ runNextSlideWebGLTask();
98
+ });
99
+ }
100
+
58
101
  type MagixEventListener = Parameters<
59
102
  AppContext<Attributes, MagixEvents>["addMagixEventListener"]
60
103
  >[1];
@@ -80,6 +123,7 @@ export class SlideControllerBase {
80
123
 
81
124
  protected visible: boolean;
82
125
  protected savedIsFrozen: boolean;
126
+ protected focused = true;
83
127
 
84
128
  protected invisibleBehavior: "frozen" | "pause";
85
129
 
@@ -132,7 +176,9 @@ export class SlideControllerBase {
132
176
  log("[Slide] render end", slideIndex);
133
177
  } else {
134
178
  setTimeout(() => {
179
+ if (this.destroyed) return;
135
180
  this.ready = true;
181
+ if (this.isLazySetupMode()) void this.reconcileActivity(true).catch(() => undefined);
136
182
  resolve();
137
183
  }, 1000);
138
184
  }
@@ -282,6 +328,10 @@ export class SlideControllerBase {
282
328
  protected pollCount = 0;
283
329
  protected pollReadyState = () => {
284
330
  if (this.ready) {
331
+ if (this.isLazySetupMode()) {
332
+ void this.reconcileActivity().catch(() => undefined);
333
+ return;
334
+ }
285
335
  if (this._toFreeze === 1) {
286
336
  this.freeze();
287
337
  } else if (this._toFreeze === -1) {
@@ -356,13 +406,30 @@ export class SlideControllerBase {
356
406
 
357
407
  protected destroyed = false;
358
408
  protected destroyPromise?: Promise<void>;
409
+ private resolveDestroyedSignal!: () => void;
410
+ private readonly destroyedSignal = new Promise<void>(resolve => {
411
+ this.resolveDestroyedSignal = resolve;
412
+ });
359
413
 
360
414
  public destroy(): Promise<void> {
361
415
  this.sideEffect.flushAll();
362
416
  if (!this.destroyed) {
363
417
  log("[Slide] destroy slide (once)");
364
418
  this.destroyed = true;
365
- this.destroyPromise = Promise.resolve(this.slide.destroy());
419
+ this.destroyPromise = enqueueSlideWebGLTransition(() => new Promise<void>((resolve, reject) => {
420
+ try {
421
+ (this.slide.destroy as (
422
+ onPlayerDestroyed?: () => void,
423
+ onError?: (error: unknown) => void
424
+ ) => void).call(this.slide, () => {
425
+ log("[Slide][player] destroy callback", this.context.appId);
426
+ resolve();
427
+ }, reject);
428
+ } catch (error) {
429
+ reject(error);
430
+ }
431
+ }), true, `destroy:${this.context.appId}`);
432
+ this.resolveDestroyedSignal();
366
433
  }
367
434
  return this.destroyPromise ?? Promise.resolve();
368
435
  }
@@ -378,49 +445,144 @@ export class SlideControllerBase {
378
445
  };
379
446
 
380
447
  public isFrozen = false;
448
+ private resourceStateUnknown = false;
449
+ private managedActivity = false;
381
450
  protected _toFreeze: -1 | 0 | 1 = 0; // -1: unfreeze, 0: no change, 1: freeze
382
451
 
383
- public freeze = (): Promise<void> => {
452
+ public setFocusedState = (focused: boolean): Promise<void> => {
453
+ this.focused = focused;
454
+ return this.reconcileActivity();
455
+ };
456
+
457
+ private resourceTransition = createFocusTransitionQueue(
458
+ undefined,
459
+ async active => {
460
+ if (this.destroyed) return;
461
+ // A queued release can become stale before the engine starts it.
462
+ if (active && this.shouldBeActive()) await this.unfreeze();
463
+ else await this.freeze();
464
+ },
465
+ error => logger.warn("[Slide] resource transition failed", this.context.appId, error)
466
+ );
467
+
468
+ private shouldBeActive = (): boolean => {
469
+ const wm = this.context.getWindowManager() as any;
470
+ const host = (this.context as any).getRuntimeActivity?.();
471
+ const lazy = this.isLazySetupMode();
472
+ // New hosts own one activity snapshot. Mixing it with the delayed UI
473
+ // boxState can acknowledge "frozen" while the host expects activation.
474
+ // Old hosts retain the original local/Attribute checks.
475
+ const minimized =
476
+ lazy && host
477
+ ? !host.active
478
+ : wm?.boxState === "minimized" ||
479
+ wm?.attributes?.minimized === true ||
480
+ this.context.getBoxStatus() === "minimized";
481
+ return (
482
+ shouldSlideRuntimeBeActive(
483
+ !lazy || this.focused,
484
+ document.visibilityState !== "hidden",
485
+ minimized
486
+ ) &&
487
+ (!lazy || host?.active !== false)
488
+ );
489
+ };
490
+
491
+ public reconcileActivity = (force = false): Promise<void> => {
492
+ if (this.destroyed) return Promise.resolve();
493
+ if (this.isLazySetupMode()) this.managedActivity = true;
494
+ if (!this.managedActivity) return Promise.resolve();
495
+ this.visible = document.visibilityState !== "hidden";
496
+ return this.resourceTransition(this.shouldBeActive(), force);
497
+ };
498
+
499
+ public freeze = async (): Promise<void> => {
500
+ const previousIsFrozen = this.isFrozen;
501
+ const previousToFreeze = this._toFreeze;
384
502
  this.isFrozen = true;
385
- if (this.ready) {
386
- log("[Slide] freeze", this.context.appId);
387
- if (this.invisibleBehavior === "frozen") {
388
- return Promise.resolve(this.slide.frozen());
503
+ try {
504
+ if (this.ready) {
505
+ log("[Slide] freeze", this.context.appId);
506
+ if (this.invisibleBehavior === "frozen") {
507
+ await enqueueSlideWebGLTransition(() => {
508
+ if (this.destroyed) return Promise.resolve();
509
+ const destroyed = new Promise<void>((resolve, reject) => {
510
+ const result = (this.slide.frozen as (
511
+ onPlayerDestroyed?: () => void,
512
+ onError?: (error: unknown) => void
513
+ ) => void | Promise<void>).call(this.slide, () => {
514
+ log("[Slide][player] frozen callback", this.context.appId);
515
+ resolve();
516
+ }, reject);
517
+ if (result) void result.then(resolve, reject);
518
+ });
519
+ return Promise.race([destroyed, this.destroyedSignal]);
520
+ }, false, `freeze:${this.context.appId}`);
521
+ } else {
522
+ this.slide.pause();
523
+ }
389
524
  } else {
390
- this.slide.pause();
525
+ this._toFreeze = 1;
391
526
  }
392
- } else {
393
- this._toFreeze = 1;
527
+ this.resourceStateUnknown = false;
528
+ } catch (error) {
529
+ this.resourceStateUnknown = true;
530
+ this.isFrozen = previousIsFrozen;
531
+ this._toFreeze = previousToFreeze;
532
+ throw error;
394
533
  }
395
- return Promise.resolve();
396
534
  };
397
535
 
398
536
  public unfreeze = async (): Promise<void> => {
399
537
  if (!this.visible) return;
400
- if (!this.isFrozen && this.ready) return;
538
+ if (!this.isFrozen && this.ready && !this.resourceStateUnknown) return;
539
+ const previousIsFrozen = this.isFrozen;
540
+ const previousToFreeze = this._toFreeze;
401
541
  this.isFrozen = false;
402
- if (this.ready) {
403
- let isNeedSyncState = false;
404
- log("[Slide] unfreeze", this.context.appId);
405
- if (this.invisibleBehavior === "frozen") {
406
- await this.slide.release(() => {
407
- // release 会重建 player,内部 observer 已关闭,需要按当前 frame 尺寸重绘
542
+ try {
543
+ if (this.ready) {
544
+ log("[Slide] unfreeze", this.context.appId);
545
+ if (this.invisibleBehavior === "frozen") {
546
+ await enqueueSlideWebGLTransition(() => {
547
+ if (this.destroyed) return Promise.resolve();
548
+ const created = new Promise<void>((resolve, reject) => {
549
+ const result = (this.slide.release as (
550
+ onRestored?: () => void,
551
+ onPlayerCreated?: () => void,
552
+ onError?: (error: unknown) => void
553
+ ) => void | Promise<void>).call(this.slide, () => {
554
+ log("[Slide][player] restored callback", this.context.appId);
555
+ if (this.isLazySetupMode() && !this.shouldBeActive()) return;
556
+ this.slide.notifyFrameResize();
557
+ const state = this.context.storage.state.state;
558
+ if (state) {
559
+ log("[Slide] sync storage", JSON.stringify(state));
560
+ void this.slide.setSlideState(state).catch(error => {
561
+ this.resourceStateUnknown = true;
562
+ logger.warn("[Slide] storage sync after release failed", this.context.appId, error);
563
+ });
564
+ }
565
+ }, () => {
566
+ log("[Slide][player] created callback", this.context.appId);
567
+ resolve();
568
+ }, reject);
569
+ if (result) void result.then(resolve, reject);
570
+ });
571
+ return Promise.race([created, this.destroyedSignal]);
572
+ }, false, `release:${this.context.appId}`);
573
+ } else {
574
+ this.slide.resume();
408
575
  this.slide.notifyFrameResize();
409
- });
410
- isNeedSyncState = true;
411
- } else {
412
- this.slide.resume();
413
- this.slide.notifyFrameResize();
414
- }
415
- if (isNeedSyncState) {
416
- const state = this.context.storage.state.state;
417
- if (state) {
418
- log("[Slide] sync storage", JSON.stringify(state));
419
- this.slide.setSlideState(state);
420
576
  }
577
+ } else {
578
+ this._toFreeze = -1;
421
579
  }
422
- } else {
423
- this._toFreeze = -1;
580
+ this.resourceStateUnknown = false;
581
+ } catch (error) {
582
+ this.resourceStateUnknown = true;
583
+ this.isFrozen = previousIsFrozen;
584
+ this._toFreeze = previousToFreeze;
585
+ throw error;
424
586
  }
425
587
  };
426
588
 
@@ -446,6 +608,10 @@ export class SlideControllerBase {
446
608
  };
447
609
 
448
610
  protected onAppStateChangeHandler = (state: "normal" | "minimized" | "maximized") => {
611
+ if (this.isLazySetupMode() || this.managedActivity) {
612
+ void this.reconcileActivity().catch(() => undefined);
613
+ return;
614
+ }
449
615
  if (state === "minimized") {
450
616
  log("[Slide] freeze because app state is minimized");
451
617
  this.freeze();
@@ -457,6 +623,10 @@ export class SlideControllerBase {
457
623
  status: "normal" | "minimized" | "maximized";
458
624
  }) => {
459
625
  const { appId, status } = payload;
626
+ if (appId === this.context.appId && (this.isLazySetupMode() || this.managedActivity)) {
627
+ void this.reconcileActivity().catch(() => undefined);
628
+ return;
629
+ }
460
630
  if (appId === this.context.appId && status === "minimized") {
461
631
  log("[Slide] freeze because app status is minimized");
462
632
  this.freeze();
@@ -479,21 +649,35 @@ export class SlideControllerBase {
479
649
  }
480
650
  };
481
651
 
652
+ protected isLazySetupMode = (): boolean => {
653
+ try {
654
+ return (this.context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
655
+ } catch {
656
+ return false;
657
+ }
658
+ };
659
+
482
660
  protected onVisibilityChange = async () => {
661
+ this.visible = document.visibilityState === "visible";
662
+ if (this.isLazySetupMode() || this.managedActivity) {
663
+ await this.reconcileActivity().catch(() => undefined);
664
+ return;
665
+ }
483
666
  const appStatus = this.getAppStatus();
667
+ if (!this.visible) {
668
+ this.savedIsFrozen = this.isFrozen;
669
+ log("[Slide] freeze because tab becomes invisible");
670
+ if (!this.isFrozen) await this.freeze();
671
+ return;
672
+ }
673
+
484
674
  if (appStatus === "minimized") {
485
675
  log("[Slide] do nothing because app state is minimized");
486
676
  return;
487
677
  }
488
- if (!(this.visible = document.visibilityState === "visible")) {
489
- this.savedIsFrozen = this.isFrozen;
490
- log("[Slide] freeze because tab becomes invisible");
491
- this.freeze();
492
- } else {
493
- if (!this.savedIsFrozen) {
494
- log("[Slide] unfreeze because tab becomes visible", { savedIsFrozen: this.savedIsFrozen });
495
- this.unfreeze();
496
- }
678
+ if (!this.savedIsFrozen) {
679
+ log("[Slide] unfreeze because tab becomes visible", { savedIsFrozen: this.savedIsFrozen });
680
+ await this.unfreeze();
497
681
  }
498
682
  };
499
683
  }
@@ -435,6 +435,7 @@ export class SlideDocsViewer {
435
435
  };
436
436
 
437
437
  protected onRenderStart = () => {
438
+ logger.log("[Slide][notes] hide", this.appId, "scene", this.whiteboardView.focusScenePath);
438
439
  this.$whiteboardView.classList.add(this.wrapClassName("wb-view-hidden"));
439
440
  this.viewer.setPlaying();
440
441
  };
@@ -447,11 +448,13 @@ export class SlideDocsViewer {
447
448
 
448
449
  public onPageChanged = () => {
449
450
  clearTimeout(this._onPageChangedTimer);
451
+ logger.log("[Slide][notes] show scheduled", this.appId, "scene", this.whiteboardView.focusScenePath);
450
452
  this._onPageChangedTimer = setTimeout(this._onPageChanged, 200) as unknown as number;
451
453
  };
452
454
 
453
455
  protected _onPageChangedTimer = 0;
454
456
  protected _onPageChanged = () => {
457
+ logger.log("[Slide][notes] show", this.appId, "scene", this.whiteboardView.focusScenePath);
455
458
  this.$overlay.style.opacity = "";
456
459
  this.$whiteboardView.classList.remove(this.wrapClassName("wb-view-hidden"));
457
460
  };
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  syncSceneWithSlide,
14
14
  SlideController,
15
15
  SlideControllerBase,
16
+ enqueueSlideWebGLTransition,
16
17
  } from "./SlideController";
17
18
  import { SlideDocsViewer } from "./SlideDocsViewer";
18
19
  import { apps, FreezerLength, addHooks, useFreezer } from "./utils/freezer";
@@ -94,8 +95,8 @@ export interface AppOptions
94
95
  resourceMaxRetries?: number;
95
96
  onResourceMaxRetries: (url: string, error: Error) => void;
96
97
  /**
97
- * Max time (ms) `setup()` waits for the first `renderEnd` before resolving
98
- * anyway (the slide keeps loading in background). Default: 5_000.
98
+ * First-render warning threshold in ms. Default: 5_000. New lazy hosts
99
+ * continue awaiting real readiness; legacy/eager hosts resolve on timeout.
99
100
  */
100
101
  setupReadyTimeout?: number;
101
102
  }
@@ -126,9 +127,7 @@ export interface AppResult {
126
127
  playPptMedia: () => void;
127
128
  }
128
129
 
129
- const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
130
- teardown(context: import("@netless/window-manager").AppContext): Promise<void>;
131
- } = {
130
+ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
132
131
  kind: "Slide",
133
132
  setup(context) {
134
133
  console.log("[Slide] setup @ " + version);
@@ -225,6 +224,8 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
225
224
  const page = slideController.slide.slideState.currentSlideIndex;
226
225
  log("[Slide] page to", page, synced ? "(synced)" : "", "(on ready)");
227
226
  slideController.slide.on("renderEnd", options.onRenderEnd);
227
+ }).catch(error => {
228
+ if (!disposed) logger.warn("[Slide] ready callback failed", context.appId, error);
228
229
  });
229
230
  return slideController;
230
231
  };
@@ -289,30 +290,19 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
289
290
  // a lazy-mode-only cache optimization: inactive unless the WindowManager
290
291
  // runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
291
292
  // be disabled at runtime, e.g. when forceMaximized is cleared).
292
- const isBlurFreezeAllowed = (): boolean => {
293
- const boxStatus = context.getBoxStatus();
294
- if (boxStatus) return boxStatus !== "normal";
295
- const boxState = context.getWindowManager()?.boxState;
296
- return boxState != null && boxState !== "normal";
297
- };
298
293
  sideEffect.add(() =>
299
- context.emitter.on("focus", async (isFocused: boolean) => {
294
+ context.emitter.on("focus", (isFocused: boolean) => {
300
295
  if (disposed) return;
301
- if (!isLazySetupMode()) return;
302
296
  const controller = docsViewer?.slideController;
303
- if (!controller) return;
304
- if (!isFocused) {
305
- if (!isBlurFreezeAllowed()) return;
306
- if (!controller.isFrozen) {
307
- log("[Slide] blur freeze", context.appId);
308
- await controller.freeze();
309
- }
310
- return;
311
- }
312
- if (controller.isFrozen) {
313
- log("[Slide] focus unfreeze", context.appId);
314
- await controller.unfreeze();
315
- }
297
+ // Emittery waits for listener return values. Propagate the transition
298
+ // promise so WindowManager commits focus only after resources are
299
+ // frozen/unfrozen and Slide state restoration has completed.
300
+ return controller?.setFocusedState(isFocused);
301
+ })
302
+ );
303
+ sideEffect.add(() =>
304
+ (context.emitter as any).on("runtimeActivity", () => {
305
+ if (!disposed) return docsViewer?.slideController?.reconcileActivity();
316
306
  })
317
307
  );
318
308
 
@@ -340,17 +330,17 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
340
330
  };
341
331
  offDestroy = context.emitter.on("destroy", teardown);
342
332
 
343
- docsViewer.mount();
344
-
345
- (SlideApp as any).__teardownByContext ||= new WeakMap<object, () => Promise<void>>();
346
- (SlideApp as any).__teardownByContext.set(context, teardown);
333
+ const mounting = enqueueSlideWebGLTransition(() => {
334
+ if (!disposed) docsViewer?.mount();
335
+ }, false, `setup:${context.appId}`);
347
336
 
348
337
  // Resolve once the SlideController finished its first render (renderEnd).
349
- // `false` means timeout or teardown; the slide then keeps loading in the
350
- // background without blocking WindowManager's serial setup queue.
338
+ // New lazy hosts retain the real completion after the warning threshold.
339
+ // Legacy/eager hosts retain timed readiness; disposal/failure rejects below.
351
340
  const waitForFirstRender = (timeoutMs: number): Promise<boolean> =>
352
341
  new Promise<boolean>(resolve => {
353
342
  let settled = false;
343
+ let timedOut = false;
354
344
  const settle = (loaded: boolean) => {
355
345
  if (settled) return;
356
346
  settled = true;
@@ -358,15 +348,23 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
358
348
  window.clearInterval(pollTimer);
359
349
  resolve(loaded);
360
350
  };
361
- const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
351
+ const timeoutTimer = window.setTimeout(() => {
352
+ timedOut = true;
353
+ logger.warn("[Slide] first render still pending", context.appId, timeoutMs);
354
+ if (!(context as any).waitForActualSetupReady) settle(false);
355
+ }, timeoutMs);
362
356
  // Success path resolves straight from readyPromise (no polling lag);
363
357
  // the poll only catches teardown and pre-ready render failures.
364
358
  slideControllerRef?.readyPromise.then(
365
359
  () => settle(!firstRenderFailed),
366
- () => settle(false)
360
+ () => {
361
+ firstRenderFailed = true;
362
+ settle(false);
363
+ }
367
364
  );
368
365
  const pollTimer = window.setInterval(() => {
369
366
  if (disposed || firstRenderFailed) settle(false);
367
+ else if (timedOut && !(context as any).waitForActualSetupReady) settle(false);
370
368
  }, 100);
371
369
  });
372
370
 
@@ -507,7 +505,11 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
507
505
  };
508
506
 
509
507
  const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
510
- return waitForFirstRender(setupReadyTimeout).then(() => {
508
+ return mounting.then(() => {
509
+ if (disposed) throw new Error("[Slide] disposed before first render");
510
+ return waitForFirstRender(setupReadyTimeout);
511
+ }).then(async () => {
512
+ if (disposed) throw new Error("[Slide] disposed before first render");
511
513
  if (firstRenderFailed) {
512
514
  throw new Error("[Slide] first render failed before ready");
513
515
  }
@@ -515,12 +517,11 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
515
517
  log("[Slide] setup ready wait timed out, slide keeps loading in background");
516
518
  }
517
519
  return appResult;
520
+ }).catch(async error => {
521
+ if (!disposed) await teardown();
522
+ throw error;
518
523
  }) as unknown as AppResult;
519
524
  },
520
- async teardown(context) {
521
- await (SlideApp as any).__teardownByContext?.get(context)?.();
522
- (SlideApp as any).__teardownByContext?.delete(context);
523
- },
524
525
  };
525
526
 
526
527
  export default SlideApp;
@@ -0,0 +1,51 @@
1
+ export type FocusTransitionHandler = (isFocused: boolean) => void | Promise<void>;
2
+
3
+ export function shouldSlideRuntimeBeActive(
4
+ isFocused: boolean,
5
+ isVisible: boolean,
6
+ isMinimized: boolean
7
+ ): boolean {
8
+ return isFocused && isVisible && !isMinimized;
9
+ }
10
+
11
+ /**
12
+ * Drops duplicate focus states and serializes opposite resource transitions.
13
+ * The state tracks accepted events so an in-flight async transition cannot be
14
+ * started twice by duplicate host signals.
15
+ */
16
+ export function createFocusTransitionQueue(
17
+ initialState: boolean | undefined,
18
+ apply: FocusTransitionHandler,
19
+ onError?: (error: unknown) => void
20
+ ): (isFocused: boolean, force?: boolean) => Promise<void> {
21
+ let acceptedState = initialState;
22
+ let revision = 0;
23
+ let queue = Promise.resolve();
24
+ let acceptedPromise = Promise.resolve();
25
+
26
+ return (isFocused, force = false) => {
27
+ // A duplicate signal must observe the same completion barrier. Returning
28
+ // early here would let the host commit focus while freeze/unfreeze is
29
+ // still running.
30
+ if (!force && isFocused === acceptedState) return acceptedPromise;
31
+ acceptedState = isFocused;
32
+ const transitionRevision = ++revision;
33
+ const transition = queue
34
+ .then(() => apply(isFocused))
35
+ .catch(error => {
36
+ // Permit a later same-state signal to retry only when no newer focus
37
+ // transition has superseded the failed request.
38
+ if (revision === transitionRevision) {
39
+ acceptedState = undefined;
40
+ acceptedPromise = Promise.resolve();
41
+ }
42
+ onError?.(error);
43
+ throw error;
44
+ });
45
+ // Keep later opposite transitions runnable after a failure, while the
46
+ // caller still receives the rejection for this exact transition.
47
+ queue = transition.catch(() => undefined);
48
+ acceptedPromise = transition;
49
+ return transition;
50
+ };
51
+ }