@netless/app-slide 0.2.106-beta.2 → 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.2",
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 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",
@@ -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
  }
package/src/index.ts CHANGED
@@ -94,8 +94,8 @@ export interface AppOptions
94
94
  resourceMaxRetries?: number;
95
95
  onResourceMaxRetries: (url: string, error: Error) => void;
96
96
  /**
97
- * Max time (ms) `setup()` waits for the first `renderEnd` before resolving
98
- * 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.
99
99
  */
100
100
  setupReadyTimeout?: number;
101
101
  }
@@ -289,30 +289,19 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
289
289
  // a lazy-mode-only cache optimization: inactive unless the WindowManager
290
290
  // runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
291
291
  // 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
292
  sideEffect.add(() =>
299
- context.emitter.on("focus", async (isFocused: boolean) => {
293
+ context.emitter.on("focus", (isFocused: boolean) => {
300
294
  if (disposed) return;
301
- if (!isLazySetupMode()) return;
302
295
  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
- }
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();
316
305
  })
317
306
  );
318
307
 
@@ -346,11 +335,12 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
346
335
  (SlideApp as any).__teardownByContext.set(context, teardown);
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,7 +348,11 @@ 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(
@@ -367,6 +361,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
367
361
  );
368
362
  const pollTimer = window.setInterval(() => {
369
363
  if (disposed || firstRenderFailed) settle(false);
364
+ else if (timedOut && !(context as any).waitForActualSetupReady) settle(false);
370
365
  }, 100);
371
366
  });
372
367
 
@@ -508,6 +503,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
508
503
 
509
504
  const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
510
505
  return waitForFirstRender(setupReadyTimeout).then(() => {
506
+ if (disposed) throw new Error("[Slide] disposed before first render");
511
507
  if (firstRenderFailed) {
512
508
  throw new Error("[Slide] first render failed before ready");
513
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
+ }