@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.
- package/dist/projection/web.d.ts +1 -1
- package/dist/projection/web.js +116 -94
- package/dist/types.d.ts +1 -0
- package/docs/advanced/web-projection.md +15 -2
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -1
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -3
- package/package.json +1 -1
- package/src/projection/web.ts +119 -93
- package/src/types.ts +1 -0
package/dist/projection/web.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/projection/web.js
CHANGED
|
@@ -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
|
|
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 [
|
|
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
|
|
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 && (
|
|
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
|
-
...(
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 (
|
|
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' && !
|
|
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 (!
|
|
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 (!
|
|
393
|
+
if (!handoff &&
|
|
383
394
|
cached &&
|
|
384
395
|
scrollOwner &&
|
|
385
|
-
latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner &&
|
|
396
|
+
latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner &&
|
|
397
|
+
(candidate.sources ?? [candidate.element]).some((source) => cached.sources.includes(source))))
|
|
386
398
|
invalidate();
|
|
387
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
439
|
-
if (
|
|
446
|
+
const abandon = (expected = handoff?.image, afterPaint = restoring()) => {
|
|
447
|
+
if (handoff?.image !== expected)
|
|
440
448
|
return;
|
|
441
|
-
const previous = new Set([
|
|
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 (!
|
|
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 !==
|
|
461
|
+
if (cached !== handoff?.image) {
|
|
456
462
|
abandon();
|
|
457
463
|
return;
|
|
458
464
|
}
|
|
459
|
-
const image =
|
|
465
|
+
const image = handoff.image;
|
|
460
466
|
presentation++;
|
|
461
467
|
win.cancelAnimationFrame(paintFrame);
|
|
462
468
|
paintFrame = 0;
|
|
463
|
-
handoffPending =
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
535
|
-
if (!image ||
|
|
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 (
|
|
543
|
+
if (handoff) {
|
|
543
544
|
if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
|
|
544
|
-
abandon(
|
|
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,
|
|
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' || (
|
|
629
|
+
if (stopped || destination === 'none' || (handoff && !force))
|
|
629
630
|
return;
|
|
630
|
-
const image =
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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 (!
|
|
683
|
-
|
|
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 =
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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 (!
|
|
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 (!
|
|
715
|
-
targetsFor(
|
|
716
|
-
(callbacks.current && !callbacks.current(
|
|
717
|
-
abandon(
|
|
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 =
|
|
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) =>
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 (
|
|
746
|
-
abandon(
|
|
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 (
|
|
761
|
+
if (handoff?.phase !== 'restoring' || revision === undefined || revision <= handoff.revision)
|
|
752
762
|
return;
|
|
753
763
|
if (!callbacks.transient && token)
|
|
754
764
|
return;
|
|
755
|
-
if (!
|
|
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 && !
|
|
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 ||
|
|
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 (
|
|
802
|
-
abandon(
|
|
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 (!
|
|
826
|
+
if (!imageRequested())
|
|
817
827
|
restore();
|
|
818
828
|
};
|
|
819
829
|
const projectionChanged = () => queueMicrotask(() => {
|
|
820
830
|
if (stopped)
|
|
821
831
|
return;
|
|
822
|
-
if (
|
|
832
|
+
if (imageRequested())
|
|
823
833
|
present();
|
|
824
|
-
else
|
|
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 [...
|
|
874
|
+
return handoff && handoff.phase !== 'restoring' ? [...handoff.image.sources] : [];
|
|
865
875
|
},
|
|
866
876
|
get visibleOwners() {
|
|
867
|
-
return [...
|
|
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
|
|
886
|
+
return restoring() ? undefined : handoff?.image.data.token;
|
|
877
887
|
},
|
|
878
888
|
begin,
|
|
879
889
|
get restoring() {
|
|
880
|
-
return
|
|
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
|
|
970
|
-
|
|
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
|
|
41
|
-
4. Exclude `projection.owners` from normal Native updates while
|
|
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),
|
|
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 },
|