@webview-native-ui/engine 1.0.2-0 → 1.0.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.
@@ -26,7 +26,7 @@ export interface WebProjectionOptions {
26
26
  captureRevision?(): number;
27
27
  /** Select this surface's sources, e.g. the underlying page or the active overlay. */
28
28
  eligible?(source: Candidate): boolean;
29
- /** Reject a cached image when its owning surface has changed. */
29
+ /** Validate the captured source set, including composite sources, against its owning surface. */
30
30
  current?(sources: HTMLElement[]): boolean;
31
31
  /** Temporary movement handoff: rebase return pixels to the current display DOM. */
32
32
  transient?: boolean;
@@ -47,6 +47,10 @@ interface Prepared {
47
47
  layout: string;
48
48
  }
49
49
 
50
+ /** One pixel handoff; requests and renderer source ownership remain independent. */
51
+ type ImageHandoff =
52
+ { phase: 'covering' | 'image'; image: Prepared } | { phase: 'restoring'; image: Prepared; visible: boolean; revision: number };
53
+
50
54
  const layout = ({ control: c }: Candidate, positionIndependent = false) => [
51
55
  c.id,
52
56
  c.kind,
@@ -68,6 +72,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
68
72
  const doc = scope.element.ownerDocument;
69
73
  const projectionRequest = getProjectionPause(doc);
70
74
  let locallyRequested = false;
75
+ const imageRequested = () => locallyRequested || projectionRequest.isPaused();
71
76
  const win = doc.defaultView!;
72
77
  // Native tokens are local to a transport; never use them as global CSS identities.
73
78
  const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
@@ -86,11 +91,15 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
86
91
  const call = transport.send.bind(transport);
87
92
  const imageMarker = 'data-native-ui-snapshot';
88
93
  const sourcesChangedEvent = `${scope.namespace}:sources-change`;
89
- const owners = new Set<HTMLElement>(),
90
- visibleOwners = new Set<HTMLElement>(),
91
- preparedOwners = new Set<HTMLElement>();
94
+ const preparedOwners = new Set<HTMLElement>();
92
95
  const scopeIDs = new WeakMap<WebProjectionScope, number>();
93
96
  let scopeSerial = 0;
97
+ const sourceIDs = new WeakMap<HTMLElement, number>();
98
+ let sourceSerial = 0;
99
+ const sourceIdentity = (source: HTMLElement) => {
100
+ if (!sourceIDs.has(source)) sourceIDs.set(source, ++sourceSerial);
101
+ return sourceIDs.get(source)!;
102
+ };
94
103
  const signature = (candidates: Candidate[]) =>
95
104
  JSON.stringify(
96
105
  candidates.map((candidate) => {
@@ -99,7 +108,11 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
99
108
  const relative = origin
100
109
  ? { ...candidate, control: { ...candidate.control, x: candidate.control.x - origin.x, y: candidate.control.y - origin.y } }
101
110
  : candidate;
102
- return [scopeIDs.get(candidate.scope), layout(relative, !!callbacks.opaqueUnderlay)];
111
+ return [
112
+ scopeIDs.get(candidate.scope),
113
+ layout(relative, !!callbacks.opaqueUnderlay),
114
+ (candidate.sources ?? [candidate.element]).map(sourceIdentity),
115
+ ];
103
116
  }),
104
117
  );
105
118
  const sheets = new Map<Document | ShadowRoot, HTMLStyleElement>();
@@ -147,16 +160,16 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
147
160
  };
148
161
  const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
149
162
  let handoffPending = false;
150
- let presented = false;
151
163
  let paintFrame = 0;
152
164
  let presentation = 0;
153
165
  let cached: Prepared | undefined;
154
166
  let retiring: object | undefined;
155
- let active: Prepared | undefined;
167
+ let handoff: ImageHandoff | undefined;
168
+ const restoring = () => handoff?.phase === 'restoring';
169
+ const imageVisible = () =>
170
+ !!handoff && (!!callbacks.opaqueUnderlay || handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible));
156
171
  let touching = false;
157
- let ending = false;
158
172
  let restoreTimeout = 0;
159
- let restoreRevision = 0;
160
173
  let generation = 0;
161
174
  let timer = 0;
162
175
  let visualRevision = 0;
@@ -168,7 +181,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
168
181
  const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
169
182
  for (const [target, pool] of imagePool) {
170
183
  const reusable = !stopped && target.isConnected && targets.has(target);
171
- if (reusable && (active || ending || handoffPending)) continue;
184
+ if (reusable && (handoff || handoffPending)) continue;
172
185
  const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
173
186
  let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
174
187
  const retained = pool.filter((image) => {
@@ -181,7 +194,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
181
194
  }
182
195
  const liveRoots = new Set<Node>([
183
196
  ...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
184
- ...(active?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
197
+ ...(handoff?.image.scopes.map((scope) => scope.element.getRootNode()) ?? []),
185
198
  ...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
186
199
  ...[...leasedImages].map((image) => image.getRootNode()),
187
200
  ...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot!)),
@@ -209,7 +222,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
209
222
  preparedSources.clear();
210
223
  };
211
224
  const dispose = (item: Prepared | undefined, afterPaint = false) => {
212
- if (!item || item === cached || item === active) return;
225
+ if (!item || item === cached || item === handoff?.image) return;
213
226
  item.images.forEach((image) => {
214
227
  retireImage(image);
215
228
  });
@@ -235,7 +248,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
235
248
  destination !== 'native' ||
236
249
  flight ||
237
250
  touching ||
238
- active ||
251
+ handoff ||
239
252
  callbacks.blocked() ||
240
253
  !latest ||
241
254
  (cached?.revision === latest.revision && preparedVisualRevision === visualRevision)
@@ -320,7 +333,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
320
333
  stopped ||
321
334
  destination !== 'native' ||
322
335
  touching ||
323
- active ||
336
+ handoff ||
324
337
  callbacks.blocked() ||
325
338
  generation !== stamp ||
326
339
  viewport() !== size ||
@@ -334,7 +347,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
334
347
  const prepared: Prepared = {
335
348
  data: result as CoveredSnapshot,
336
349
  images,
337
- sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
350
+ sources: Array.from(new Set(selected.flatMap((item) => item.sources ?? [item.element]))),
338
351
  targets: partSources.map((source) => source.element),
339
352
  scopes: partSources.map((source) => source.scope),
340
353
  parents: partSources.map((source) => source.scope.element.parentNode),
@@ -371,7 +384,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
371
384
  preparedVisualRevision = visual;
372
385
  adopted = true;
373
386
  dispose(previous);
374
- if (projectionRequest.isPaused()) present();
387
+ if (imageRequested()) present();
375
388
  } catch {
376
389
  // Preparing a visual is optional. Preserve the previous image or the Web fallback.
377
390
  } finally {
@@ -388,7 +401,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
388
401
  // available while their replacement is being rendered and decoded.
389
402
  const schedule = () => {
390
403
  win.clearTimeout(timer);
391
- if (!stopped && destination === 'native' && !active && !touching && !callbacks.blocked())
404
+ if (!stopped && destination === 'native' && !handoff && !touching && !callbacks.blocked())
392
405
  timer = win.setTimeout(() => void prepare(), captureDelayMs);
393
406
  };
394
407
  let consideredLayout: string | undefined;
@@ -404,7 +417,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
404
417
  // Overlay retirement is not a new source layout. Preserve the pre-overlay
405
418
  // image until the presentation path has chosen whether to use it.
406
419
  if (callbacks.blocked()) return;
407
- if (!active && cached && cached.layout !== signature(select(candidates))) invalidate();
420
+ if (!handoff && cached && cached.layout !== signature(select(candidates))) invalidate();
408
421
  const nextLayout = signature(candidates);
409
422
  if (consideredLayout !== nextLayout) {
410
423
  consideredLayout = nextLayout;
@@ -420,13 +433,17 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
420
433
  // A pre-interaction state image is reusable; an image from another scroll
421
434
  // position/clip is not. Invalidate only the surface containing that owner.
422
435
  if (
423
- !active &&
436
+ !handoff &&
424
437
  cached &&
425
438
  scrollOwner &&
426
- latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner && cached!.sources.includes(candidate.element))
439
+ latest?.candidates.some(
440
+ (candidate) =>
441
+ candidate.scrollElement === scrollOwner &&
442
+ (candidate.sources ?? [candidate.element]).some((source) => cached!.sources.includes(source)),
443
+ )
427
444
  )
428
445
  invalidate();
429
- if (active) {
446
+ if (handoff) {
430
447
  updateOffsets();
431
448
  }
432
449
  visualRevision++;
@@ -461,12 +478,8 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
461
478
  offsets.clear();
462
479
  paintFrame = 0;
463
480
  handoffPending = false;
464
- presented = false;
465
481
  clearPrepared();
466
- active?.sources.forEach((source) => {
467
- owners.delete(source);
468
- visibleOwners.delete(source);
469
- });
482
+ handoff = undefined;
470
483
  rendered.forEach(clearTarget);
471
484
  rendered.clear();
472
485
  sheets.forEach((style, root) => {
@@ -476,38 +489,31 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
476
489
  }
477
490
  });
478
491
  };
479
- const abandon = (expected = active, afterPaint = ending) => {
480
- if (active !== expected) return;
481
- const previous = new Set([active, cached]);
492
+ const abandon = (expected = handoff?.image, afterPaint = restoring()) => {
493
+ if (handoff?.image !== expected) return;
494
+ const previous = new Set([handoff?.image, cached]);
482
495
  detach();
483
- active = undefined;
484
496
  cached = undefined;
485
- ending = false;
486
497
  win.clearTimeout(restoreTimeout);
487
498
  previous.forEach((item) => dispose(item, afterPaint));
488
499
  callbacks.wake();
489
500
  };
490
501
  const finishRestoration = () => {
491
- if (!active) return;
502
+ if (!handoff) return;
492
503
  // A moved return invalidates the cached coordinates, but keeps the active
493
504
  // visual until Native has taken ownership at its destination.
494
- if (cached !== active) {
505
+ if (cached !== handoff?.image) {
495
506
  abandon();
496
507
  return;
497
508
  }
498
- const image = active;
509
+ const image = handoff.image;
499
510
  presentation++;
500
511
  win.cancelAnimationFrame(paintFrame);
501
512
  paintFrame = 0;
502
- handoffPending = presented = false;
503
- image.sources.forEach((source) => {
504
- owners.delete(source);
505
- visibleOwners.delete(source);
506
- });
513
+ handoffPending = false;
507
514
  rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
508
515
  showImages(image, !!callbacks.opaqueUnderlay);
509
- active = undefined;
510
- ending = false;
516
+ handoff = undefined;
511
517
  win.clearTimeout(restoreTimeout);
512
518
  // Retire only the temporary return copy. Keep the pre-interaction image
513
519
  // available until a replacement finishes, including rapid overlay reopening.
@@ -522,7 +528,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
522
528
  callbacks.wake();
523
529
  };
524
530
  const retireRestoration = () => {
525
- if (ending) finishRestoration();
531
+ if (restoring()) finishRestoration();
526
532
  pendingCleanup.forEach((run) => run());
527
533
  };
528
534
  const targetsFor = (image: Prepared) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
@@ -568,16 +574,16 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
568
574
  if (element.style.getPropertyValue(property) !== next) element.style.setProperty(property, next, 'important');
569
575
  }
570
576
  };
571
- const render = (image = active ?? cached) => {
572
- if (!image || ending) return false;
577
+ const render = (image = handoff?.image ?? cached) => {
578
+ if (!image || restoring()) return false;
573
579
  if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
574
580
  invalidate();
575
581
  return false;
576
582
  }
577
583
  const targets = targetsFor(image);
578
- if (active) {
584
+ if (handoff) {
579
585
  if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
580
- abandon(active, false);
586
+ abandon(handoff.image, false);
581
587
  return false;
582
588
  }
583
589
  const next = new Set<HTMLElement>();
@@ -592,7 +598,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
592
598
  for (const target of rendered) if (!next.has(target)) clearTarget(target);
593
599
  rendered.clear();
594
600
  next.forEach((target) => rendered.add(target));
595
- showImages(image, presented);
601
+ showImages(image, imageVisible());
596
602
  return true;
597
603
  }
598
604
  imagePool.forEach((pool) =>
@@ -661,13 +667,13 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
661
667
 
662
668
  const present = (force = false) => {
663
669
  retireRestoration();
664
- if (stopped || destination === 'none' || (active && !force)) return;
665
- const image = active ?? cached;
670
+ if (stopped || destination === 'none' || (handoff && !force)) return;
671
+ const image = handoff?.image ?? cached;
666
672
  if (
667
673
  !image ||
668
674
  image.viewport !== viewport() ||
669
675
  !image.sources.every(validSource) ||
670
- (callbacks.current && !callbacks.current(image.targets)) ||
676
+ (callbacks.current && !callbacks.current(image.sources)) ||
671
677
  image.targets.some((source) => !preparedKeys.has(source)) ||
672
678
  targetsFor(image).some((target) => !target?.isConnected) ||
673
679
  [...rendered].some((target) => !target.isConnected)
@@ -681,16 +687,12 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
681
687
  const currentPresentation = ++presentation;
682
688
  const isCurrent = () => presentation === currentPresentation;
683
689
  handoffPending = true;
684
- presented = !!callbacks.opaqueUnderlay;
685
- active = image;
690
+ handoff = { phase: 'covering', image };
686
691
  // Reuse decoded pixels; no capture, bridge acknowledgement or animation wait.
687
692
  generation++;
688
693
  win.clearTimeout(timer);
689
- image.sources.forEach((source) => {
690
- owners.add(source);
691
- });
692
694
  rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
693
- showImages(image, presented);
695
+ showImages(image, imageVisible());
694
696
  // Reconciliation may continue after one frame even if the optional bridge reply
695
697
  // is delayed. The ordered native transport processes cover before that update.
696
698
  paintFrame = win.requestAnimationFrame(() => {
@@ -715,23 +717,26 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
715
717
  }
716
718
  // Live translucent Native must be hidden before its composited Web image
717
719
  // becomes visible. Native's prepared entry copy spans this bridge interval.
718
- if (!ending) {
719
- presented = true;
720
- image.sources.forEach((source) => visibleOwners.add(source));
720
+ if (!restoring()) {
721
+ handoff = { phase: 'image', image };
721
722
  render();
722
723
  updateOffsets();
723
724
  }
724
725
  win.cancelAnimationFrame(paintFrame);
726
+ // rAF callbacks run before paint. Cross a paint boundary before retiring
727
+ // Native's entry copy, keeping it over WebKit's compositing interval.
725
728
  paintFrame = win.requestAnimationFrame(() => {
726
- paintFrame = 0;
727
- if (!isCurrent()) return;
728
- void call({ action: 'painted', token: image.data.token })
729
- .then((result) => {
730
- if (isCurrent() && result?.shown === false) abandon(image, false);
731
- })
732
- .catch(() => {
733
- if (isCurrent()) abandon(image, false);
734
- });
729
+ paintFrame = win.requestAnimationFrame(() => {
730
+ paintFrame = 0;
731
+ if (!isCurrent()) return;
732
+ void call({ action: 'painted', token: image.data.token })
733
+ .then((result) => {
734
+ if (isCurrent() && result?.shown === false) abandon(image, false);
735
+ })
736
+ .catch(() => {
737
+ if (isCurrent()) abandon(image, false);
738
+ });
739
+ });
735
740
  });
736
741
  callbacks.wake();
737
742
  })
@@ -740,20 +745,20 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
740
745
  });
741
746
  };
742
747
  const restore = () => {
743
- if (!active || stopped || ending) return;
748
+ if (!handoff || stopped || restoring()) return;
744
749
  // A return belongs to the captured surface, never to the modal exposed beneath it.
745
750
  if (
746
- !active.sources.every(validSource) ||
747
- targetsFor(active).some((target) => !target) ||
748
- (callbacks.current && !callbacks.current(active.targets))
751
+ !handoff.image.sources.every(validSource) ||
752
+ targetsFor(handoff.image).some((target) => !target) ||
753
+ (callbacks.current && !callbacks.current(handoff.image.sources))
749
754
  ) {
750
- abandon(active, false);
755
+ abandon(handoff.image, false);
751
756
  return;
752
757
  }
753
758
  if (callbacks.blocked()) return;
754
759
  updateOffsets();
755
760
  if (callbacks.transient) {
756
- const image = active;
761
+ const image = handoff.image;
757
762
  const targets = targetsFor(image);
758
763
  if (targets.some((target) => !target?.isConnected)) {
759
764
  abandon(image, false);
@@ -764,25 +769,37 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
764
769
  // image until its replacement is ready; resizing still invalidates it.
765
770
  if (
766
771
  callbacks.opaqueUnderlay ||
767
- parts.every((part, index) => part.width === image.data.parts[index].width && part.height === image.data.parts[index].height)
772
+ parts.every((part, index) => {
773
+ const original = image.data.parts[index];
774
+ const scale = win.devicePixelRatio || 1;
775
+ // Transformed DOMRects can differ by floating-point noise. Reuse pixels
776
+ // only when their raster dimensions still match at the display scale.
777
+ return (
778
+ Math.round(part.width * scale) === Math.round(original.width * scale) &&
779
+ Math.round(part.height * scale) === Math.round(original.height * scale)
780
+ );
781
+ })
768
782
  )
769
783
  image.data = { ...image.data, parts };
770
784
  else cached = undefined;
771
785
  // Both requests use the same ordered bridge. Reprojection need not await this optional copy.
772
786
  void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => {});
773
787
  }
774
- restoreRevision = callbacks.revision?.() ?? latest?.revision ?? 0;
775
- ending = true;
776
- active.sources.forEach((source) => owners.delete(source));
788
+ handoff = {
789
+ phase: 'restoring',
790
+ image: handoff.image,
791
+ visible: handoff.phase === 'image',
792
+ revision: callbacks.revision?.() ?? latest?.revision ?? 0,
793
+ };
777
794
  restoreTimeout = win.setTimeout(() => {
778
- if (ending) abandon(active, false);
795
+ if (handoff?.phase === 'restoring') abandon(handoff.image, false);
779
796
  }, 250);
780
797
  callbacks.wake();
781
798
  };
782
799
  const updated = (token: string | undefined, restoredIDs?: string[], revision?: number) => {
783
- if (!ending || !active || revision === undefined || revision <= restoreRevision) return;
800
+ if (handoff?.phase !== 'restoring' || revision === undefined || revision <= handoff.revision) return;
784
801
  if (!callbacks.transient && token) return;
785
- if (!active.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id)))) return;
802
+ if (!handoff.image.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id)))) return;
786
803
  // Retire the Web image at the native restore acknowledgement, not after
787
804
  // another complete reconciliation/paint cycle over live glass.
788
805
  finishRestoration();
@@ -795,7 +812,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
795
812
  const previous = cached;
796
813
  cached = undefined;
797
814
  if (nativeHidden || stopped) retiring = undefined;
798
- if (callbacks.retainPreparedUntilRelease && !active && !nativeHidden && !stopped) {
815
+ if (callbacks.retainPreparedUntilRelease && !handoff && !nativeHidden && !stopped) {
799
816
  if (previous) {
800
817
  const pending = {};
801
818
  retiring = pending;
@@ -807,7 +824,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
807
824
  });
808
825
  if (retiring !== pending) return;
809
826
  retiring = undefined;
810
- if (cached || active) return;
827
+ if (cached || handoff) return;
811
828
  // Another session can already own the same host; retire only our own DOM markers.
812
829
  for (const target of rendered) {
813
830
  if (target.getAttribute(imageMarker) !== preparedKeys.get(target)) {
@@ -823,7 +840,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
823
840
  if (retiring) return;
824
841
  }
825
842
  dispose(previous);
826
- if (active) abandon(active, false);
843
+ if (handoff) abandon(handoff.image, false);
827
844
  else {
828
845
  detach();
829
846
  preparedKeys.clear();
@@ -837,13 +854,13 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
837
854
  };
838
855
  const end = () => {
839
856
  locallyRequested = false;
840
- if (!projectionRequest.isPaused()) restore();
857
+ if (!imageRequested()) restore();
841
858
  };
842
859
  const projectionChanged = () =>
843
860
  queueMicrotask(() => {
844
861
  if (stopped) return;
845
- if (projectionRequest.isPaused()) present();
846
- else if (!locallyRequested) restore();
862
+ if (imageRequested()) present();
863
+ else restore();
847
864
  callbacks.wake();
848
865
  });
849
866
  let eventListener: Awaited<ReturnType<typeof bindProjectionEvents>> | undefined;
@@ -876,10 +893,10 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
876
893
  const stopPause = projectionRequest.subscribe(projectionChanged);
877
894
  return {
878
895
  get owners(): readonly HTMLElement[] {
879
- return [...owners];
896
+ return handoff && handoff.phase !== 'restoring' ? [...handoff.image.sources] : [];
880
897
  },
881
898
  get visibleOwners(): readonly HTMLElement[] {
882
- return [...visibleOwners];
899
+ return handoff && (handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible)) ? [...handoff.image.sources] : [];
883
900
  },
884
901
  get preparedOwners(): readonly HTMLElement[] {
885
902
  return [...preparedOwners];
@@ -888,11 +905,11 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
888
905
  changed,
889
906
  moved: updateOffsets,
890
907
  get token() {
891
- return ending ? undefined : active?.data.token;
908
+ return restoring() ? undefined : handoff?.image.data.token;
892
909
  },
893
910
  begin,
894
911
  get restoring() {
895
- return ending;
912
+ return restoring();
896
913
  },
897
914
  get handoffPending() {
898
915
  return handoffPending;
@@ -945,6 +962,7 @@ export const createWebProjectionBatch = async (
945
962
  let latest: { revision: number; sources: Candidate[] } | undefined;
946
963
  let stopped = false;
947
964
  let batchDocument: Document | undefined;
965
+ let pendingRequest = false;
948
966
  return {
949
967
  get owners() {
950
968
  return engine?.owners ?? [];
@@ -990,6 +1008,8 @@ export const createWebProjectionBatch = async (
990
1008
  return;
991
1009
  }
992
1010
  engine = created;
1011
+ if (pendingRequest) engine.begin();
1012
+ pendingRequest = false;
993
1013
  if (latest) engine.consider(latest.revision, latest.sources);
994
1014
  })
995
1015
  .catch(() => {
@@ -999,8 +1019,14 @@ export const createWebProjectionBatch = async (
999
1019
  },
1000
1020
  changed: (owner) => engine?.changed(owner),
1001
1021
  moved: () => engine?.moved(),
1002
- begin: () => engine?.begin(),
1003
- end: () => engine?.end(),
1022
+ begin() {
1023
+ if (engine) engine.begin();
1024
+ else pendingRequest = true;
1025
+ },
1026
+ end() {
1027
+ pendingRequest = false;
1028
+ engine?.end();
1029
+ },
1004
1030
  updated: (token, ids, revision) => engine?.updated(token, ids, revision),
1005
1031
  retireRestoration: () => engine?.retireRestoration(),
1006
1032
  invalidate: (nativeHidden?: boolean) => engine?.invalidate(nativeHidden),
package/src/types.ts CHANGED
@@ -21,6 +21,7 @@ export interface ScrollPlacement {
21
21
  /** A source and its native rendering. Kind/placement are opaque layout identity keys. */
22
22
  export interface WebProjectionSource {
23
23
  element: HTMLElement;
24
+ /** Exact DOM sources represented by this control. Replaces the default [element] when supplied. */
24
25
  sources?: HTMLElement[];
25
26
  scrollElement?: HTMLElement;
26
27
  control: Frame & { id: string; kind?: string; placement?: string; dark?: boolean; rtl?: boolean; scroll?: ScrollPlacement };