@webview-native-ui/engine 1.0.2 → 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.
@@ -15,7 +15,7 @@ export interface WebProjectionOptions {
15
15
  captureRevision?(): number;
16
16
  /** Select this surface's sources, e.g. the underlying page or the active overlay. */
17
17
  eligible?(source: Candidate): boolean;
18
- /** Reject a cached image when its owning surface has changed. */
18
+ /** Validate the captured source set, including composite sources, against its owning surface. */
19
19
  current?(sources: HTMLElement[]): boolean;
20
20
  /** Temporary movement handoff: rebase return pixels to the current display DOM. */
21
21
  transient?: boolean;
@@ -23,6 +23,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
23
23
  const doc = scope.element.ownerDocument;
24
24
  const projectionRequest = getProjectionPause(doc);
25
25
  let locallyRequested = false;
26
+ const imageRequested = () => locallyRequested || projectionRequest.isPaused();
26
27
  const win = doc.defaultView;
27
28
  // Native tokens are local to a transport; never use them as global CSS identities.
28
29
  const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
@@ -41,9 +42,16 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
41
42
  const call = transport.send.bind(transport);
42
43
  const imageMarker = 'data-native-ui-snapshot';
43
44
  const sourcesChangedEvent = `${scope.namespace}:sources-change`;
44
- const owners = new Set(), visibleOwners = new Set(), preparedOwners = new Set();
45
+ const preparedOwners = new Set();
45
46
  const scopeIDs = new WeakMap();
46
47
  let scopeSerial = 0;
48
+ const sourceIDs = new WeakMap();
49
+ let sourceSerial = 0;
50
+ const sourceIdentity = (source) => {
51
+ if (!sourceIDs.has(source))
52
+ sourceIDs.set(source, ++sourceSerial);
53
+ return sourceIDs.get(source);
54
+ };
47
55
  const signature = (candidates) => JSON.stringify(candidates.map((candidate) => {
48
56
  if (!scopeIDs.has(candidate.scope))
49
57
  scopeIDs.set(candidate.scope, ++scopeSerial);
@@ -51,7 +59,11 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
51
59
  const relative = origin
52
60
  ? { ...candidate, control: { ...candidate.control, x: candidate.control.x - origin.x, y: candidate.control.y - origin.y } }
53
61
  : candidate;
54
- return [scopeIDs.get(candidate.scope), layout(relative, !!callbacks.opaqueUnderlay)];
62
+ return [
63
+ scopeIDs.get(candidate.scope),
64
+ layout(relative, !!callbacks.opaqueUnderlay),
65
+ (candidate.sources ?? [candidate.element]).map(sourceIdentity),
66
+ ];
55
67
  }));
56
68
  const sheets = new Map();
57
69
  const rootFor = (element) => {
@@ -104,16 +116,15 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
104
116
  };
105
117
  const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
106
118
  let handoffPending = false;
107
- let presented = false;
108
119
  let paintFrame = 0;
109
120
  let presentation = 0;
110
121
  let cached;
111
122
  let retiring;
112
- let active;
123
+ let handoff;
124
+ const restoring = () => handoff?.phase === 'restoring';
125
+ const imageVisible = () => !!handoff && (!!callbacks.opaqueUnderlay || handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible));
113
126
  let touching = false;
114
- let ending = false;
115
127
  let restoreTimeout = 0;
116
- let restoreRevision = 0;
117
128
  let generation = 0;
118
129
  let timer = 0;
119
130
  let visualRevision = 0;
@@ -125,7 +136,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
125
136
  const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
126
137
  for (const [target, pool] of imagePool) {
127
138
  const reusable = !stopped && target.isConnected && targets.has(target);
128
- if (reusable && (active || ending || handoffPending))
139
+ if (reusable && (handoff || handoffPending))
129
140
  continue;
130
141
  const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
131
142
  let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
@@ -142,7 +153,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
142
153
  }
143
154
  const liveRoots = new Set([
144
155
  ...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
145
- ...(active?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
156
+ ...(handoff?.image.scopes.map((scope) => scope.element.getRootNode()) ?? []),
146
157
  ...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
147
158
  ...[...leasedImages].map((image) => image.getRootNode()),
148
159
  ...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot)),
@@ -174,7 +185,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
174
185
  preparedSources.clear();
175
186
  };
176
187
  const dispose = (item, afterPaint = false) => {
177
- if (!item || item === cached || item === active)
188
+ if (!item || item === cached || item === handoff?.image)
178
189
  return;
179
190
  item.images.forEach((image) => {
180
191
  retireImage(image);
@@ -200,7 +211,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
200
211
  destination !== 'native' ||
201
212
  flight ||
202
213
  touching ||
203
- active ||
214
+ handoff ||
204
215
  callbacks.blocked() ||
205
216
  !latest ||
206
217
  (cached?.revision === latest.revision && preparedVisualRevision === visualRevision))
@@ -275,7 +286,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
275
286
  if (stopped ||
276
287
  destination !== 'native' ||
277
288
  touching ||
278
- active ||
289
+ handoff ||
279
290
  callbacks.blocked() ||
280
291
  generation !== stamp ||
281
292
  viewport() !== size ||
@@ -286,7 +297,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
286
297
  const prepared = {
287
298
  data: result,
288
299
  images,
289
- sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
300
+ sources: Array.from(new Set(selected.flatMap((item) => item.sources ?? [item.element]))),
290
301
  targets: partSources.map((source) => source.element),
291
302
  scopes: partSources.map((source) => source.scope),
292
303
  parents: partSources.map((source) => source.scope.element.parentNode),
@@ -327,7 +338,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
327
338
  preparedVisualRevision = visual;
328
339
  adopted = true;
329
340
  dispose(previous);
330
- if (projectionRequest.isPaused())
341
+ if (imageRequested())
331
342
  present();
332
343
  }
333
344
  catch {
@@ -349,7 +360,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
349
360
  // available while their replacement is being rendered and decoded.
350
361
  const schedule = () => {
351
362
  win.clearTimeout(timer);
352
- if (!stopped && destination === 'native' && !active && !touching && !callbacks.blocked())
363
+ if (!stopped && destination === 'native' && !handoff && !touching && !callbacks.blocked())
353
364
  timer = win.setTimeout(() => void prepare(), captureDelayMs);
354
365
  };
355
366
  let consideredLayout;
@@ -361,7 +372,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
361
372
  // image until the presentation path has chosen whether to use it.
362
373
  if (callbacks.blocked())
363
374
  return;
364
- if (!active && cached && cached.layout !== signature(select(candidates)))
375
+ if (!handoff && cached && cached.layout !== signature(select(candidates)))
365
376
  invalidate();
366
377
  const nextLayout = signature(candidates);
367
378
  if (consideredLayout !== nextLayout) {
@@ -379,12 +390,13 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
379
390
  const changed = (scrollOwner) => {
380
391
  // A pre-interaction state image is reusable; an image from another scroll
381
392
  // position/clip is not. Invalidate only the surface containing that owner.
382
- if (!active &&
393
+ if (!handoff &&
383
394
  cached &&
384
395
  scrollOwner &&
385
- latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner && cached.sources.includes(candidate.element)))
396
+ latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner &&
397
+ (candidate.sources ?? [candidate.element]).some((source) => cached.sources.includes(source))))
386
398
  invalidate();
387
- if (active) {
399
+ if (handoff) {
388
400
  updateOffsets();
389
401
  }
390
402
  visualRevision++;
@@ -420,12 +432,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
420
432
  offsets.clear();
421
433
  paintFrame = 0;
422
434
  handoffPending = false;
423
- presented = false;
424
435
  clearPrepared();
425
- active?.sources.forEach((source) => {
426
- owners.delete(source);
427
- visibleOwners.delete(source);
428
- });
436
+ handoff = undefined;
429
437
  rendered.forEach(clearTarget);
430
438
  rendered.clear();
431
439
  sheets.forEach((style, root) => {
@@ -435,40 +443,33 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
435
443
  }
436
444
  });
437
445
  };
438
- const abandon = (expected = active, afterPaint = ending) => {
439
- if (active !== expected)
446
+ const abandon = (expected = handoff?.image, afterPaint = restoring()) => {
447
+ if (handoff?.image !== expected)
440
448
  return;
441
- const previous = new Set([active, cached]);
449
+ const previous = new Set([handoff?.image, cached]);
442
450
  detach();
443
- active = undefined;
444
451
  cached = undefined;
445
- ending = false;
446
452
  win.clearTimeout(restoreTimeout);
447
453
  previous.forEach((item) => dispose(item, afterPaint));
448
454
  callbacks.wake();
449
455
  };
450
456
  const finishRestoration = () => {
451
- if (!active)
457
+ if (!handoff)
452
458
  return;
453
459
  // A moved return invalidates the cached coordinates, but keeps the active
454
460
  // visual until Native has taken ownership at its destination.
455
- if (cached !== active) {
461
+ if (cached !== handoff?.image) {
456
462
  abandon();
457
463
  return;
458
464
  }
459
- const image = active;
465
+ const image = handoff.image;
460
466
  presentation++;
461
467
  win.cancelAnimationFrame(paintFrame);
462
468
  paintFrame = 0;
463
- handoffPending = presented = false;
464
- image.sources.forEach((source) => {
465
- owners.delete(source);
466
- visibleOwners.delete(source);
467
- });
469
+ handoffPending = false;
468
470
  rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
469
471
  showImages(image, !!callbacks.opaqueUnderlay);
470
- active = undefined;
471
- ending = false;
472
+ handoff = undefined;
472
473
  win.clearTimeout(restoreTimeout);
473
474
  // Retire only the temporary return copy. Keep the pre-interaction image
474
475
  // available until a replacement finishes, including rapid overlay reopening.
@@ -483,7 +484,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
483
484
  callbacks.wake();
484
485
  };
485
486
  const retireRestoration = () => {
486
- if (ending)
487
+ if (restoring())
487
488
  finishRestoration();
488
489
  pendingCleanup.forEach((run) => run());
489
490
  };
@@ -531,17 +532,17 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
531
532
  element.style.setProperty(property, next, 'important');
532
533
  }
533
534
  };
534
- const render = (image = active ?? cached) => {
535
- if (!image || ending)
535
+ const render = (image = handoff?.image ?? cached) => {
536
+ if (!image || restoring())
536
537
  return false;
537
538
  if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
538
539
  invalidate();
539
540
  return false;
540
541
  }
541
542
  const targets = targetsFor(image);
542
- if (active) {
543
+ if (handoff) {
543
544
  if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
544
- abandon(active, false);
545
+ abandon(handoff.image, false);
545
546
  return false;
546
547
  }
547
548
  const next = new Set();
@@ -559,7 +560,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
559
560
  clearTarget(target);
560
561
  rendered.clear();
561
562
  next.forEach((target) => rendered.add(target));
562
- showImages(image, presented);
563
+ showImages(image, imageVisible());
563
564
  return true;
564
565
  }
565
566
  imagePool.forEach((pool) => pool.forEach((element) => {
@@ -625,13 +626,13 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
625
626
  });
626
627
  const present = (force = false) => {
627
628
  retireRestoration();
628
- if (stopped || destination === 'none' || (active && !force))
629
+ if (stopped || destination === 'none' || (handoff && !force))
629
630
  return;
630
- const image = active ?? cached;
631
+ const image = handoff?.image ?? cached;
631
632
  if (!image ||
632
633
  image.viewport !== viewport() ||
633
634
  !image.sources.every(validSource) ||
634
- (callbacks.current && !callbacks.current(image.targets)) ||
635
+ (callbacks.current && !callbacks.current(image.sources)) ||
635
636
  image.targets.some((source) => !preparedKeys.has(source)) ||
636
637
  targetsFor(image).some((target) => !target?.isConnected) ||
637
638
  [...rendered].some((target) => !target.isConnected))
@@ -645,16 +646,12 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
645
646
  const currentPresentation = ++presentation;
646
647
  const isCurrent = () => presentation === currentPresentation;
647
648
  handoffPending = true;
648
- presented = !!callbacks.opaqueUnderlay;
649
- active = image;
649
+ handoff = { phase: 'covering', image };
650
650
  // Reuse decoded pixels; no capture, bridge acknowledgement or animation wait.
651
651
  generation++;
652
652
  win.clearTimeout(timer);
653
- image.sources.forEach((source) => {
654
- owners.add(source);
655
- });
656
653
  rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
657
- showImages(image, presented);
654
+ showImages(image, imageVisible());
658
655
  // Reconciliation may continue after one frame even if the optional bridge reply
659
656
  // is delayed. The ordered native transport processes cover before that update.
660
657
  paintFrame = win.requestAnimationFrame(() => {
@@ -679,25 +676,28 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
679
676
  }
680
677
  // Live translucent Native must be hidden before its composited Web image
681
678
  // becomes visible. Native's prepared entry copy spans this bridge interval.
682
- if (!ending) {
683
- presented = true;
684
- image.sources.forEach((source) => visibleOwners.add(source));
679
+ if (!restoring()) {
680
+ handoff = { phase: 'image', image };
685
681
  render();
686
682
  updateOffsets();
687
683
  }
688
684
  win.cancelAnimationFrame(paintFrame);
685
+ // rAF callbacks run before paint. Cross a paint boundary before retiring
686
+ // Native's entry copy, keeping it over WebKit's compositing interval.
689
687
  paintFrame = win.requestAnimationFrame(() => {
690
- paintFrame = 0;
691
- if (!isCurrent())
692
- return;
693
- void call({ action: 'painted', token: image.data.token })
694
- .then((result) => {
695
- if (isCurrent() && result?.shown === false)
696
- abandon(image, false);
697
- })
698
- .catch(() => {
699
- if (isCurrent())
700
- abandon(image, false);
688
+ paintFrame = win.requestAnimationFrame(() => {
689
+ paintFrame = 0;
690
+ if (!isCurrent())
691
+ return;
692
+ void call({ action: 'painted', token: image.data.token })
693
+ .then((result) => {
694
+ if (isCurrent() && result?.shown === false)
695
+ abandon(image, false);
696
+ })
697
+ .catch(() => {
698
+ if (isCurrent())
699
+ abandon(image, false);
700
+ });
701
701
  });
702
702
  });
703
703
  callbacks.wake();
@@ -708,20 +708,20 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
708
708
  });
709
709
  };
710
710
  const restore = () => {
711
- if (!active || stopped || ending)
711
+ if (!handoff || stopped || restoring())
712
712
  return;
713
713
  // A return belongs to the captured surface, never to the modal exposed beneath it.
714
- if (!active.sources.every(validSource) ||
715
- targetsFor(active).some((target) => !target) ||
716
- (callbacks.current && !callbacks.current(active.targets))) {
717
- abandon(active, false);
714
+ if (!handoff.image.sources.every(validSource) ||
715
+ targetsFor(handoff.image).some((target) => !target) ||
716
+ (callbacks.current && !callbacks.current(handoff.image.sources))) {
717
+ abandon(handoff.image, false);
718
718
  return;
719
719
  }
720
720
  if (callbacks.blocked())
721
721
  return;
722
722
  updateOffsets();
723
723
  if (callbacks.transient) {
724
- const image = active;
724
+ const image = handoff.image;
725
725
  const targets = targetsFor(image);
726
726
  if (targets.some((target) => !target?.isConnected)) {
727
727
  abandon(image, false);
@@ -731,28 +731,38 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
731
731
  // Translation changes placement, not pixels. Keep the pre-interaction
732
732
  // image until its replacement is ready; resizing still invalidates it.
733
733
  if (callbacks.opaqueUnderlay ||
734
- parts.every((part, index) => part.width === image.data.parts[index].width && part.height === image.data.parts[index].height))
734
+ parts.every((part, index) => {
735
+ const original = image.data.parts[index];
736
+ const scale = win.devicePixelRatio || 1;
737
+ // Transformed DOMRects can differ by floating-point noise. Reuse pixels
738
+ // only when their raster dimensions still match at the display scale.
739
+ return (Math.round(part.width * scale) === Math.round(original.width * scale) &&
740
+ Math.round(part.height * scale) === Math.round(original.height * scale));
741
+ }))
735
742
  image.data = { ...image.data, parts };
736
743
  else
737
744
  cached = undefined;
738
745
  // Both requests use the same ordered bridge. Reprojection need not await this optional copy.
739
746
  void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => { });
740
747
  }
741
- restoreRevision = callbacks.revision?.() ?? latest?.revision ?? 0;
742
- ending = true;
743
- active.sources.forEach((source) => owners.delete(source));
748
+ handoff = {
749
+ phase: 'restoring',
750
+ image: handoff.image,
751
+ visible: handoff.phase === 'image',
752
+ revision: callbacks.revision?.() ?? latest?.revision ?? 0,
753
+ };
744
754
  restoreTimeout = win.setTimeout(() => {
745
- if (ending)
746
- abandon(active, false);
755
+ if (handoff?.phase === 'restoring')
756
+ abandon(handoff.image, false);
747
757
  }, 250);
748
758
  callbacks.wake();
749
759
  };
750
760
  const updated = (token, restoredIDs, revision) => {
751
- if (!ending || !active || revision === undefined || revision <= restoreRevision)
761
+ if (handoff?.phase !== 'restoring' || revision === undefined || revision <= handoff.revision)
752
762
  return;
753
763
  if (!callbacks.transient && token)
754
764
  return;
755
- if (!active.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id))))
765
+ if (!handoff.image.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id))))
756
766
  return;
757
767
  // Retire the Web image at the native restore acknowledgement, not after
758
768
  // another complete reconciliation/paint cycle over live glass.
@@ -767,7 +777,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
767
777
  cached = undefined;
768
778
  if (nativeHidden || stopped)
769
779
  retiring = undefined;
770
- if (callbacks.retainPreparedUntilRelease && !active && !nativeHidden && !stopped) {
780
+ if (callbacks.retainPreparedUntilRelease && !handoff && !nativeHidden && !stopped) {
771
781
  if (previous) {
772
782
  const pending = {};
773
783
  retiring = pending;
@@ -780,7 +790,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
780
790
  if (retiring !== pending)
781
791
  return;
782
792
  retiring = undefined;
783
- if (cached || active)
793
+ if (cached || handoff)
784
794
  return;
785
795
  // Another session can already own the same host; retire only our own DOM markers.
786
796
  for (const target of rendered) {
@@ -798,8 +808,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
798
808
  return;
799
809
  }
800
810
  dispose(previous);
801
- if (active)
802
- abandon(active, false);
811
+ if (handoff)
812
+ abandon(handoff.image, false);
803
813
  else {
804
814
  detach();
805
815
  preparedKeys.clear();
@@ -813,15 +823,15 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
813
823
  };
814
824
  const end = () => {
815
825
  locallyRequested = false;
816
- if (!projectionRequest.isPaused())
826
+ if (!imageRequested())
817
827
  restore();
818
828
  };
819
829
  const projectionChanged = () => queueMicrotask(() => {
820
830
  if (stopped)
821
831
  return;
822
- if (projectionRequest.isPaused())
832
+ if (imageRequested())
823
833
  present();
824
- else if (!locallyRequested)
834
+ else
825
835
  restore();
826
836
  callbacks.wake();
827
837
  });
@@ -861,10 +871,10 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
861
871
  const stopPause = projectionRequest.subscribe(projectionChanged);
862
872
  return {
863
873
  get owners() {
864
- return [...owners];
874
+ return handoff && handoff.phase !== 'restoring' ? [...handoff.image.sources] : [];
865
875
  },
866
876
  get visibleOwners() {
867
- return [...visibleOwners];
877
+ return handoff && (handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible)) ? [...handoff.image.sources] : [];
868
878
  },
869
879
  get preparedOwners() {
870
880
  return [...preparedOwners];
@@ -873,11 +883,11 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
873
883
  changed,
874
884
  moved: updateOffsets,
875
885
  get token() {
876
- return ending ? undefined : active?.data.token;
886
+ return restoring() ? undefined : handoff?.image.data.token;
877
887
  },
878
888
  begin,
879
889
  get restoring() {
880
- return ending;
890
+ return restoring();
881
891
  },
882
892
  get handoffPending() {
883
893
  return handoffPending;
@@ -914,6 +924,7 @@ export const createWebProjectionBatch = async (transport, options) => {
914
924
  let latest;
915
925
  let stopped = false;
916
926
  let batchDocument;
927
+ let pendingRequest = false;
917
928
  return {
918
929
  get owners() {
919
930
  return engine?.owners ?? [];
@@ -956,6 +967,9 @@ export const createWebProjectionBatch = async (transport, options) => {
956
967
  return;
957
968
  }
958
969
  engine = created;
970
+ if (pendingRequest)
971
+ engine.begin();
972
+ pendingRequest = false;
959
973
  if (latest)
960
974
  engine.consider(latest.revision, latest.sources);
961
975
  })
@@ -966,8 +980,16 @@ export const createWebProjectionBatch = async (transport, options) => {
966
980
  },
967
981
  changed: (owner) => engine?.changed(owner),
968
982
  moved: () => engine?.moved(),
969
- begin: () => engine?.begin(),
970
- end: () => engine?.end(),
983
+ begin() {
984
+ if (engine)
985
+ engine.begin();
986
+ else
987
+ pendingRequest = true;
988
+ },
989
+ end() {
990
+ pendingRequest = false;
991
+ engine?.end();
992
+ },
971
993
  updated: (token, ids, revision) => engine?.updated(token, ids, revision),
972
994
  retireRestoration: () => engine?.retireRestoration(),
973
995
  invalidate: (nativeHidden) => engine?.invalidate(nativeHidden),
package/dist/types.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface ScrollPlacement {
20
20
  /** A source and its native rendering. Kind/placement are opaque layout identity keys. */
21
21
  export interface WebProjectionSource {
22
22
  element: HTMLElement;
23
+ /** Exact DOM sources represented by this control. Replaces the default [element] when supplied. */
23
24
  sources?: HTMLElement[];
24
25
  scrollElement?: HTMLElement;
25
26
  control: Frame & {
@@ -37,11 +37,24 @@ The container, bridge and update callbacks above belong to the consuming plugin.
37
37
 
38
38
  1. After an accepted native update, call `projection.consider(revision, sources)`. Sources associate the native IDs and viewport frames with their source DOM; they need not be inside the fallback element. Images are captured and decoded in advance.
39
39
  2. Call `projection.changed()` for appearance changes, or `projection.changed(scrollElement)` to invalidate scroll geometry. Layout updates go through `consider`; use `invalidate()` for other geometry changes.
40
- 3. Before covering Native with a Web overlay, call `projection.begin()`. It displays already prepared pixels at the fixed fallback. No new capture or decode starts here. If capture is unavailable, the plugin keeps its ordinary Web fallback.
41
- 4. Exclude `projection.owners` from normal Native updates while the image owns those sources. `visibleOwners` keeps their return pixels while Native is being restored; `preparedOwners` identifies sources with prepared images. These arrays are read-only snapshots.
40
+ 3. Before covering Native with a Web overlay, call `projection.begin()`. It displays already prepared pixels at the fixed fallback. No new capture or decode starts here. If no image is ready, no pixel lease is acquired. The plugin retains its existing ownership and chooses how to handle unavailable images.
41
+ 4. Exclude `projection.owners` from normal Native updates while their pixels are delegated to the image. `visibleOwners` keeps their return pixels while Native is being restored; `preparedOwners` identifies sources with prepared images. These arrays are read-only snapshots.
42
42
  5. Call `projection.end()`, send the normal native update, then call `projection.updated(snapshotToken, acceptedIDs, revision)`. A newer acknowledgement containing all captured IDs retires the image.
43
43
  6. Call `projection.destroy()` when disposing the surface. It releases only its own images and listener; the plugin owns final Native reset and its DOM.
44
44
 
45
+ Image requests, pixel handoffs, and renderer ownership are separate. `begin()` requests an image; it does not release Native ownership or imply that pixels are ready. The request survives lazy initialization and preparation until `end()` cancels it. A Document pause holds the same request independently.
46
+
47
+ | Image state | `owners` | `visibleOwners` | Renderer action |
48
+ | ------------------------------- | ---------------- | -------------------------- | ----------------------------------------------------------------------------------------- |
49
+ | Idle / preparing | Empty | Empty | Keep normal Native rendering. A prepared cache alone does not transfer pixels. |
50
+ | Cover pending (prepared pixels) | Captured sources | Empty | Use the ordered cover transport; Native's entry copy bridges its acknowledgement. |
51
+ | Image displayed | Captured sources | Captured sources | Temporarily omit these controls from Native updates, retaining renderer source ownership. |
52
+ | Restoring Native | Empty | Previously visible sources | Send the Native controls again. Keep the image until a newer, complete acknowledgement. |
53
+
54
+ The image state is the single source for these read-only arrays. Motion, cache misses, and the end of an image handoff do not themselves return a renderer's source to Web. The consuming renderer makes that decision for removed/ineligible sources, rejected Native controls, or disposal. The `painted` notification crosses a rendering opportunity before retiring Native's entry copy; it is not a compositor completion API.
55
+
56
+ For composite controls, `sources` declares the exact DOM elements represented by the image and replaces the default `[element]`. The `element` remains the control/layout anchor; include it explicitly only when the image also represents that whole element. For a partial button group, list only the selected children so unselected siblings retain their own presentation. For searchable tabs, include the represented trigger/footer companions. `options.current(sources)` receives this same captured source set when validating presentation and restoration, so moving a companion to another context invalidates its old image even if the layout anchor remains connected.
57
+
45
58
  The engine prepares a noninteractive `img` inside the fallback container (directly inside its open ShadowRoot when present) while Native is visible. It reuses the decoded image and DOM node across handoffs, and releases them when the scope is retired or destroyed. The image is `inert`, hidden from accessibility and excluded from hit testing. The existing fallback and its children are never cloned, replaced, removed or assigned `inert`/`aria-hidden`; their listeners, values and application changes survive projection. Both `::before` and `::after` remain available to the application.
46
59
 
47
60
  Supply an existing container such as `div`, `button` or a custom element. Replaced/void elements such as `img`, `input` and `video` remain unsupported as scopes; use their existing fallback container. The renderer may move the same container between parents, but must not replace a low-level scope's element or let two managers control it simultaneously. Notify the manager through `moved()` when changing its placement during projection.
@@ -29,6 +29,7 @@ public final class NativeUICoveredSnapshots {
29
29
  public private(set) var coveringToken: String?
30
30
  /// Dispatch the renderer's existing ordered image-handoff transport on the main thread.
31
31
  /// The renderer supplies eligible native bounds and the complete views that may be hidden.
32
+ /// A nil view selection rejects the handoff; an empty selection means the valid image has no live views to retire.
32
33
  public func handle(_ request: [String: Any], in webView: UIView,
33
34
  frames: @escaping (Set<String>, Int) -> [String: CGRect],
34
35
  views: (Set<String>) -> [UIView]?, complete: @escaping ([String: Any]) -> Void) {
@@ -50,7 +51,7 @@ public final class NativeUICoveredSnapshots {
50
51
  case "cover":
51
52
  let ids = identifiers(for: token)
52
53
  let destination = request["parts"] == nil ? nil : projectionFrames(request, in: webView)
53
- guard !ids.isEmpty, let selected = views(ids), !selected.isEmpty,
54
+ guard !ids.isEmpty, let selected = views(ids),
54
55
  request["parts"] == nil || destination != nil else { complete(["shown": false]); return }
55
56
  var seen = Set<ObjectIdentifier>()
56
57
  complete(["shown": cover(token, in: webView, views: selected.filter { seen.insert(ObjectIdentifier($0)).inserted },