@netless/app-slide 0.2.106-beta.1 → 0.2.106-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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netless/app-slide",
3
- "version": "0.2.106-beta.1",
3
+ "version": "0.2.106-beta.3",
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 --bundle --platform=node --format=cjs --outfile=test/.cache/ResizableContainer.test.cjs && node test/.cache/ResizableContainer.test.cjs"
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-beta.0",
21
+ "@netless/slide": "1.4.61",
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";
@@ -80,6 +81,7 @@ export class SlideControllerBase {
80
81
 
81
82
  protected visible: boolean;
82
83
  protected savedIsFrozen: boolean;
84
+ protected focused = true;
83
85
 
84
86
  protected invisibleBehavior: "frozen" | "pause";
85
87
 
@@ -132,7 +134,9 @@ export class SlideControllerBase {
132
134
  log("[Slide] render end", slideIndex);
133
135
  } else {
134
136
  setTimeout(() => {
137
+ if (this.destroyed) return;
135
138
  this.ready = true;
139
+ if (this.isLazySetupMode()) void this.reconcileActivity(true).catch(() => undefined);
136
140
  resolve();
137
141
  }, 1000);
138
142
  }
@@ -282,6 +286,10 @@ export class SlideControllerBase {
282
286
  protected pollCount = 0;
283
287
  protected pollReadyState = () => {
284
288
  if (this.ready) {
289
+ if (this.isLazySetupMode()) {
290
+ void this.reconcileActivity().catch(() => undefined);
291
+ return;
292
+ }
285
293
  if (this._toFreeze === 1) {
286
294
  this.freeze();
287
295
  } else if (this._toFreeze === -1) {
@@ -378,49 +386,117 @@ export class SlideControllerBase {
378
386
  };
379
387
 
380
388
  public isFrozen = false;
389
+ private resourceStateUnknown = false;
390
+ private managedActivity = false;
381
391
  protected _toFreeze: -1 | 0 | 1 = 0; // -1: unfreeze, 0: no change, 1: freeze
382
392
 
383
- public freeze = (): Promise<void> => {
393
+ public setFocusedState = (focused: boolean): Promise<void> => {
394
+ this.focused = focused;
395
+ return this.reconcileActivity();
396
+ };
397
+
398
+ private resourceTransition = createFocusTransitionQueue(
399
+ undefined,
400
+ async active => {
401
+ if (this.destroyed) return;
402
+ // A queued release can become stale before the engine starts it.
403
+ if (active && this.shouldBeActive()) await this.unfreeze();
404
+ else await this.freeze();
405
+ },
406
+ error => logger.warn("[Slide] resource transition failed", this.context.appId, error)
407
+ );
408
+
409
+ private shouldBeActive = (): boolean => {
410
+ const wm = this.context.getWindowManager() as any;
411
+ const host = (this.context as any).getRuntimeActivity?.();
412
+ const lazy = this.isLazySetupMode();
413
+ // New hosts own one activity snapshot. Mixing it with the delayed UI
414
+ // boxState can acknowledge "frozen" while the host expects activation.
415
+ // Old hosts retain the original local/Attribute checks.
416
+ const minimized =
417
+ lazy && host
418
+ ? !host.active
419
+ : wm?.boxState === "minimized" ||
420
+ wm?.attributes?.minimized === true ||
421
+ this.context.getBoxStatus() === "minimized";
422
+ return (
423
+ shouldSlideRuntimeBeActive(
424
+ !lazy || this.focused,
425
+ document.visibilityState !== "hidden",
426
+ minimized
427
+ ) &&
428
+ (!lazy || host?.active !== false)
429
+ );
430
+ };
431
+
432
+ public reconcileActivity = (force = false): Promise<void> => {
433
+ if (this.destroyed) return Promise.resolve();
434
+ if (this.isLazySetupMode()) this.managedActivity = true;
435
+ if (!this.managedActivity) return Promise.resolve();
436
+ this.visible = document.visibilityState !== "hidden";
437
+ return this.resourceTransition(this.shouldBeActive(), force);
438
+ };
439
+
440
+ public freeze = async (): Promise<void> => {
441
+ const previousIsFrozen = this.isFrozen;
442
+ const previousToFreeze = this._toFreeze;
384
443
  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());
444
+ try {
445
+ if (this.ready) {
446
+ log("[Slide] freeze", this.context.appId);
447
+ if (this.invisibleBehavior === "frozen") {
448
+ await this.slide.frozen();
449
+ } else {
450
+ this.slide.pause();
451
+ }
389
452
  } else {
390
- this.slide.pause();
453
+ this._toFreeze = 1;
391
454
  }
392
- } else {
393
- this._toFreeze = 1;
455
+ this.resourceStateUnknown = false;
456
+ } catch (error) {
457
+ this.resourceStateUnknown = true;
458
+ this.isFrozen = previousIsFrozen;
459
+ this._toFreeze = previousToFreeze;
460
+ throw error;
394
461
  }
395
- return Promise.resolve();
396
462
  };
397
463
 
398
464
  public unfreeze = async (): Promise<void> => {
399
465
  if (!this.visible) return;
400
- if (!this.isFrozen && this.ready) return;
466
+ if (!this.isFrozen && this.ready && !this.resourceStateUnknown) return;
467
+ const previousIsFrozen = this.isFrozen;
468
+ const previousToFreeze = this._toFreeze;
401
469
  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 尺寸重绘
470
+ try {
471
+ if (this.ready) {
472
+ let isNeedSyncState = false;
473
+ log("[Slide] unfreeze", this.context.appId);
474
+ if (this.invisibleBehavior === "frozen") {
475
+ await this.slide.release(() => {
476
+ // release 会重建 player,内部 observer 已关闭,需要按当前 frame 尺寸重绘
477
+ if (!this.isLazySetupMode() || this.shouldBeActive()) this.slide.notifyFrameResize();
478
+ });
479
+ isNeedSyncState = true;
480
+ } else {
481
+ this.slide.resume();
408
482
  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
483
  }
484
+ if (isNeedSyncState && (!this.isLazySetupMode() || this.shouldBeActive())) {
485
+ const state = this.context.storage.state.state;
486
+ if (state) {
487
+ log("[Slide] sync storage", JSON.stringify(state));
488
+ await this.slide.setSlideState(state);
489
+ }
490
+ }
491
+ } else {
492
+ this._toFreeze = -1;
421
493
  }
422
- } else {
423
- this._toFreeze = -1;
494
+ this.resourceStateUnknown = false;
495
+ } catch (error) {
496
+ this.resourceStateUnknown = true;
497
+ this.isFrozen = previousIsFrozen;
498
+ this._toFreeze = previousToFreeze;
499
+ throw error;
424
500
  }
425
501
  };
426
502
 
@@ -446,6 +522,10 @@ export class SlideControllerBase {
446
522
  };
447
523
 
448
524
  protected onAppStateChangeHandler = (state: "normal" | "minimized" | "maximized") => {
525
+ if (this.isLazySetupMode() || this.managedActivity) {
526
+ void this.reconcileActivity().catch(() => undefined);
527
+ return;
528
+ }
449
529
  if (state === "minimized") {
450
530
  log("[Slide] freeze because app state is minimized");
451
531
  this.freeze();
@@ -457,6 +537,10 @@ export class SlideControllerBase {
457
537
  status: "normal" | "minimized" | "maximized";
458
538
  }) => {
459
539
  const { appId, status } = payload;
540
+ if (appId === this.context.appId && (this.isLazySetupMode() || this.managedActivity)) {
541
+ void this.reconcileActivity().catch(() => undefined);
542
+ return;
543
+ }
460
544
  if (appId === this.context.appId && status === "minimized") {
461
545
  log("[Slide] freeze because app status is minimized");
462
546
  this.freeze();
@@ -479,21 +563,35 @@ export class SlideControllerBase {
479
563
  }
480
564
  };
481
565
 
566
+ protected isLazySetupMode = (): boolean => {
567
+ try {
568
+ return (this.context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
569
+ } catch {
570
+ return false;
571
+ }
572
+ };
573
+
482
574
  protected onVisibilityChange = async () => {
575
+ this.visible = document.visibilityState === "visible";
576
+ if (this.isLazySetupMode() || this.managedActivity) {
577
+ await this.reconcileActivity().catch(() => undefined);
578
+ return;
579
+ }
483
580
  const appStatus = this.getAppStatus();
581
+ if (!this.visible) {
582
+ this.savedIsFrozen = this.isFrozen;
583
+ log("[Slide] freeze because tab becomes invisible");
584
+ if (!this.isFrozen) await this.freeze();
585
+ return;
586
+ }
587
+
484
588
  if (appStatus === "minimized") {
485
589
  log("[Slide] do nothing because app state is minimized");
486
590
  return;
487
591
  }
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
- }
592
+ if (!this.savedIsFrozen) {
593
+ log("[Slide] unfreeze because tab becomes visible", { savedIsFrozen: this.savedIsFrozen });
594
+ await this.unfreeze();
497
595
  }
498
596
  };
499
597
  }
@@ -12,10 +12,11 @@ import { SideEffectManager } from "side-effect-manager";
12
12
  import { createDocsViewerPages } from "../SlideController";
13
13
  import { DocsViewer, type DocsViewerPage } from "../DocsViewer";
14
14
  import { ResizableContainer } from "../DocsViewer/ResizableContainer";
15
+ import { navigateWithNavigationButton, resolveNavigationButtonMode } from "./navigation";
15
16
  import { logger } from "../utils/logger";
16
17
  import { isEditable } from "../utils/helpers";
17
18
  import type { Attributes, MagixEvents } from "../typings";
18
- import type { AppOptions } from "..";
19
+ import type { AppOptions, NavigationButtonMode } from "..";
19
20
  import type { SyncEventQueuePolicy } from "@netless/slide";
20
21
  import { ResizeObserver as ResizeObserverPolyfill } from "@juggle/resize-observer";
21
22
 
@@ -55,6 +56,7 @@ export interface SlideDocsViewerConfig {
55
56
  appId: string;
56
57
  urlInterrupter?: (url: string) => Promise<string>;
57
58
  enableScale?: boolean;
59
+ navigationButtonMode?: NavigationButtonMode;
58
60
  onPagesReady?: (pages: DocsViewerPage[]) => void;
59
61
  onNavigate?: (index: number, origin?: string) => void;
60
62
  }
@@ -79,6 +81,7 @@ export class SlideDocsViewer {
79
81
  protected isViewMounted = false;
80
82
  protected justSildeReadonly = false;
81
83
  protected syncEventQueuePolicy: SyncEventQueuePolicy = "fifo";
84
+ private readonly navigationButtonMode: NavigationButtonMode;
82
85
  private enableScale: boolean;
83
86
  private boxSizeEventCount = 0;
84
87
  private latestBoxSize: BoxSize | undefined;
@@ -98,6 +101,7 @@ export class SlideDocsViewer {
98
101
  appId,
99
102
  urlInterrupter,
100
103
  enableScale,
104
+ navigationButtonMode,
101
105
  onPagesReady,
102
106
  onNavigate,
103
107
  }: SlideDocsViewerConfig) {
@@ -109,6 +113,7 @@ export class SlideDocsViewer {
109
113
  this.onNavigate = onNavigate || noop;
110
114
  this.baseScenePath = baseScenePath;
111
115
  this.enableScale = enableScale ?? false;
116
+ this.navigationButtonMode = resolveNavigationButtonMode(navigationButtonMode);
112
117
  this.appId = appId;
113
118
  this.viewer = new DocsViewer({
114
119
  readonly: box.readonly,
@@ -298,6 +303,7 @@ export class SlideDocsViewer {
298
303
  }
299
304
 
300
305
  public mount() {
306
+ this.unmountPromise = undefined;
301
307
  this.box.mountContent(this.viewer.$content);
302
308
  this.box.mountFooter(this.viewer.$footer);
303
309
 
@@ -465,29 +471,42 @@ export class SlideDocsViewer {
465
471
  return (page > 0 ? page : 1) - 1;
466
472
  }
467
473
 
468
- public async unmount(): Promise<this> {
469
- this.offBoxSizeChange?.();
470
- this.offBoxSizeChange = undefined;
471
- this.contentResizeObserver?.disconnect();
472
- this.contentResizeObserver = undefined;
473
- if (this.slideController) {
474
- const controller = this.slideController;
475
- this.slideController = null;
476
- await controller.destroy();
474
+ protected unmountPromise?: Promise<this>;
475
+ protected destroyPromise?: Promise<void>;
476
+
477
+ public unmount(): Promise<this> {
478
+ if (!this.unmountPromise) {
479
+ this.unmountPromise = Promise.resolve().then(async () => {
480
+ this.offBoxSizeChange?.();
481
+ this.offBoxSizeChange = undefined;
482
+ this.contentResizeObserver?.disconnect();
483
+ this.contentResizeObserver = undefined;
484
+ if (this.slideController) {
485
+ const controller = this.slideController;
486
+ this.slideController = null;
487
+ await controller.destroy();
488
+ }
489
+ this.viewer.unmount();
490
+ this.resizableContainer.destroy(this.box);
491
+ return this;
492
+ });
477
493
  }
478
- this.viewer.unmount();
479
- this.resizableContainer.destroy(this.box);
480
- return this;
494
+ return this.unmountPromise;
481
495
  }
482
496
 
483
497
  public setReadonly(readonly: boolean) {
484
498
  this.viewer.setReadonly(readonly);
485
499
  }
486
500
 
487
- public async destroy(): Promise<void> {
488
- this.sideEffect.flushAll();
489
- await this.unmount();
490
- this.viewer.destroy();
501
+ public destroy(): Promise<void> {
502
+ if (!this.destroyPromise) {
503
+ this.destroyPromise = Promise.resolve().then(async () => {
504
+ this.sideEffect.flushAll();
505
+ await this.unmount();
506
+ this.viewer.destroy();
507
+ });
508
+ }
509
+ return this.destroyPromise;
491
510
  }
492
511
 
493
512
  public toggleClickThrough(tool?: string) {
@@ -541,7 +560,14 @@ export class SlideDocsViewer {
541
560
 
542
561
  protected onNewPageIndex = (index: number, origin?: string) => {
543
562
  if (this.slideController) {
544
- this.slideController.jumpToPage(index + 1, origin);
563
+ navigateWithNavigationButton(
564
+ this.navigationButtonMode,
565
+ origin,
566
+ this.viewer.pageIndex,
567
+ index,
568
+ this.slideController,
569
+ this.onNavigate
570
+ );
545
571
  }
546
572
  };
547
573
 
@@ -0,0 +1,33 @@
1
+ import type { NavigationButtonMode } from "..";
2
+
3
+ export type NavigationButtonAction = "prevStep" | "nextStep";
4
+
5
+ export function resolveNavigationButtonMode(
6
+ mode: NavigationButtonMode | undefined
7
+ ): NavigationButtonMode {
8
+ return mode ?? "page";
9
+ }
10
+
11
+ interface NavigationController {
12
+ readonly page: number;
13
+ readonly slide: Record<NavigationButtonAction, () => void>;
14
+ jumpToPage(page: number, origin?: string): void;
15
+ }
16
+
17
+ export function navigateWithNavigationButton(
18
+ mode: NavigationButtonMode,
19
+ origin: string | undefined,
20
+ currentPageIndex: number,
21
+ targetPageIndex: number,
22
+ controller: NavigationController,
23
+ onNavigate: (page: number, origin?: string) => void
24
+ ): void {
25
+ if (mode === "step" && origin === "navigation") {
26
+ const action: NavigationButtonAction =
27
+ targetPageIndex < currentPageIndex ? "prevStep" : "nextStep";
28
+ controller.slide[action]();
29
+ onNavigate(controller.page, origin);
30
+ return;
31
+ }
32
+ controller.jumpToPage(targetPageIndex + 1, origin);
33
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export { DefaultUrl, apps, FreezerLength, addHooks, useFreezer, log, logger };
43
43
 
44
44
  export { setFreezerLength, getFreezerLength, onCreated, onDestroyed } from "./utils/freezer";
45
45
 
46
+ export type NavigationButtonMode = "page" | "step";
47
+
46
48
  export interface AppOptions
47
49
  extends Pick<
48
50
  ISlideConfig,
@@ -86,12 +88,14 @@ export interface AppOptions
86
88
  invisibleBehavior?: "frozen" | "pause";
87
89
  /** just readonly, no operate silder */
88
90
  justSildeReadonly?: true;
91
+ /** footer navigation button behavior @default 'page' */
92
+ navigationButtonMode?: NavigationButtonMode;
89
93
  enableScale?: boolean;
90
94
  resourceMaxRetries?: number;
91
95
  onResourceMaxRetries: (url: string, error: Error) => void;
92
96
  /**
93
- * Max time (ms) `setup()` waits for the first `renderEnd` before resolving
94
- * anyway (the slide keeps loading in background). Default: 5_000.
97
+ * First-render warning threshold in ms. Default: 5_000. New lazy hosts
98
+ * continue awaiting real readiness; legacy/eager hosts resolve on timeout.
95
99
  */
96
100
  setupReadyTimeout?: number;
97
101
  }
@@ -235,6 +239,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
235
239
  appId: context.appId,
236
240
  urlInterrupter: context.getAppOptions()?.urlInterrupter,
237
241
  enableScale: context.getAppOptions()?.enableScale ?? false,
242
+ navigationButtonMode: context.getAppOptions()?.navigationButtonMode,
238
243
  onPagesReady: ({ length }) => {
239
244
  const index = docsViewer?.viewer.pageIndex || 0;
240
245
  context.dispatchAppEvent("pageStateChange", { index, length });
@@ -284,49 +289,43 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
284
289
  // a lazy-mode-only cache optimization: inactive unless the WindowManager
285
290
  // runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
286
291
  // be disabled at runtime, e.g. when forceMaximized is cleared).
287
- const isBlurFreezeAllowed = (): boolean => {
288
- const boxStatus = context.getBoxStatus();
289
- if (boxStatus) return boxStatus !== "normal";
290
- const boxState = context.getWindowManager()?.boxState;
291
- return boxState != null && boxState !== "normal";
292
- };
293
292
  sideEffect.add(() =>
294
- context.emitter.on("focus", async (isFocused: boolean) => {
293
+ context.emitter.on("focus", (isFocused: boolean) => {
295
294
  if (disposed) return;
296
- if (!isLazySetupMode()) return;
297
295
  const controller = docsViewer?.slideController;
298
- if (!controller) return;
299
- if (!isFocused) {
300
- if (!isBlurFreezeAllowed()) return;
301
- if (!controller.isFrozen) {
302
- log("[Slide] blur freeze", context.appId);
303
- await controller.freeze();
304
- }
305
- return;
306
- }
307
- if (controller.isFrozen) {
308
- log("[Slide] focus unfreeze", context.appId);
309
- await controller.unfreeze();
310
- }
296
+ // Emittery waits for listener return values. Propagate the transition
297
+ // promise so WindowManager commits focus only after resources are
298
+ // frozen/unfrozen and Slide state restoration has completed.
299
+ return controller?.setFocusedState(isFocused);
300
+ })
301
+ );
302
+ sideEffect.add(() =>
303
+ (context.emitter as any).on("runtimeActivity", () => {
304
+ if (!disposed) return docsViewer?.slideController?.reconcileActivity();
311
305
  })
312
306
  );
313
307
 
314
308
  let disposed = false;
315
309
  let offDestroy: (() => void) | undefined;
316
- const teardown = async (): Promise<void> => {
317
- if (disposed) return;
310
+ let teardownPromise: Promise<void> | undefined;
311
+ const teardown = (): Promise<void> => {
312
+ if (teardownPromise) return teardownPromise;
318
313
  disposed = true;
319
- const removeDestroy = offDestroy;
320
- offDestroy = undefined;
321
- removeDestroy?.();
322
- log("[Slide] destroy", context.appId);
323
- if (useFreezer) apps.delete(context.appId);
324
- sideEffect.flushAll();
325
- if (docsViewer) {
326
- const viewer = docsViewer;
327
- docsViewer = null;
328
- await viewer.destroy();
329
- }
314
+ // Store the promise before cleanup can synchronously trigger another teardown.
315
+ teardownPromise = Promise.resolve().then(async () => {
316
+ const removeDestroy = offDestroy;
317
+ offDestroy = undefined;
318
+ removeDestroy?.();
319
+ log("[Slide] destroy", context.appId);
320
+ if (useFreezer) apps.delete(context.appId);
321
+ sideEffect.flushAll();
322
+ if (docsViewer) {
323
+ const viewer = docsViewer;
324
+ docsViewer = null;
325
+ await viewer.destroy();
326
+ }
327
+ });
328
+ return teardownPromise;
330
329
  };
331
330
  offDestroy = context.emitter.on("destroy", teardown);
332
331
 
@@ -336,11 +335,12 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
336
335
  (SlideApp as any).__teardownByContext.set(context, teardown);
337
336
 
338
337
  // Resolve once the SlideController finished its first render (renderEnd).
339
- // `false` means timeout or teardown; the slide then keeps loading in the
340
- // 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.
341
340
  const waitForFirstRender = (timeoutMs: number): Promise<boolean> =>
342
341
  new Promise<boolean>(resolve => {
343
342
  let settled = false;
343
+ let timedOut = false;
344
344
  const settle = (loaded: boolean) => {
345
345
  if (settled) return;
346
346
  settled = true;
@@ -348,7 +348,11 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
348
348
  window.clearInterval(pollTimer);
349
349
  resolve(loaded);
350
350
  };
351
- 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);
352
356
  // Success path resolves straight from readyPromise (no polling lag);
353
357
  // the poll only catches teardown and pre-ready render failures.
354
358
  slideControllerRef?.readyPromise.then(
@@ -357,6 +361,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
357
361
  );
358
362
  const pollTimer = window.setInterval(() => {
359
363
  if (disposed || firstRenderFailed) settle(false);
364
+ else if (timedOut && !(context as any).waitForActualSetupReady) settle(false);
360
365
  }, 100);
361
366
  });
362
367
 
@@ -498,6 +503,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
498
503
 
499
504
  const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
500
505
  return waitForFirstRender(setupReadyTimeout).then(() => {
506
+ if (disposed) throw new Error("[Slide] disposed before first render");
501
507
  if (firstRenderFailed) {
502
508
  throw new Error("[Slide] first render failed before ready");
503
509
  }
@@ -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
+ }