@webview-native-ui/engine 0.0.3 → 0.0.5

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.
@@ -1,8 +1,7 @@
1
1
  import { projectionURL } from '../native/resource-url.js';
2
2
  import { bounded, boundedListener } from '../native/bridge.js';
3
3
  import { bindProjectionEvents } from './events.js';
4
- import { projectionAncestors } from '../dom/geometry.js';
5
- import { snapshotStyles } from './styles.js';
4
+ import { snapshotStyles, snapshotShadowStyles } from './styles.js';
6
5
  const layout = (candidates, positionIndependent = false) => JSON.stringify(candidates.map(({ control: c }) => [
7
6
  c.id,
8
7
  c.kind,
@@ -14,41 +13,6 @@ const layout = (candidates, positionIndependent = false) => JSON.stringify(candi
14
13
  c.dark,
15
14
  c.rtl,
16
15
  ]));
17
- // Native frames are viewport pixels; absolute children use the target's local
18
- // padding box. Keep the conversion independent of theme dimensions.
19
- const coordinateSpace = (target, win, style = win.getComputedStyle(target)) => {
20
- const rect = target.getBoundingClientRect();
21
- // offsetWidth/Height round fractional CSS sizes, which would introduce drift.
22
- const borderBox = (size, sides) => {
23
- const value = parseFloat(size);
24
- return style.display !== 'inline' && Number.isFinite(value)
25
- ? value +
26
- (style.boxSizing === 'border-box' ? 0 : sides.reduce((sum, side) => sum + (parseFloat(style.getPropertyValue(side)) || 0), 0))
27
- : NaN;
28
- };
29
- const width = borderBox(style.width, ['padding-left', 'padding-right', 'border-left-width', 'border-right-width']);
30
- const height = borderBox(style.height, ['padding-top', 'padding-bottom', 'border-top-width', 'border-bottom-width']);
31
- // Inline dimensions may be auto (or an ignored specified width). Native element
32
- // projection excludes transformed ancestors; use CSS zoom, not rounded offsets.
33
- let zoom = 1;
34
- if (!(width > 0 && height > 0)) {
35
- for (const element of [target, ...projectionAncestors(target)]) {
36
- const value = (element === target ? style : win.getComputedStyle(element)).getPropertyValue('zoom');
37
- zoom *= (parseFloat(value) || 1) / (value.endsWith('%') ? 100 : 1);
38
- }
39
- }
40
- const scaleX = width > 0 ? rect.width / width : zoom;
41
- const scaleY = height > 0 ? rect.height / height : zoom;
42
- return {
43
- x: rect.x,
44
- y: rect.y,
45
- scaleX: scaleX || 1,
46
- scaleY: scaleY || 1,
47
- left: (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft,
48
- top: (parseFloat(style.borderTopWidth) || 0) - target.scrollTop,
49
- position: style.position,
50
- };
51
- };
52
16
  /** Images are prepared after accepted native updates, never on the overlay's critical path. */
53
17
  const createProjectionEngine = async (scope, transport, callbacks) => {
54
18
  const captureDelayMs = callbacks.captureDelayMs ?? 50;
@@ -61,6 +25,16 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
61
25
  const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
62
26
  const validSource = (source) => source.isConnected && source.ownerDocument === doc;
63
27
  const validTarget = (target) => !!target?.isConnected && target.ownerDocument === doc;
28
+ // Keep current, preparing and pending-release images leased; reuse idle DOM slots.
29
+ const imagePool = new Map();
30
+ const leasedImages = new Set();
31
+ const retireImage = (image) => {
32
+ leasedImages.delete(image);
33
+ image.style.setProperty('display', 'none', 'important');
34
+ image.removeAttribute('data-native-ui-projection-image');
35
+ image.removeAttribute('src');
36
+ pruneIdleImages();
37
+ };
64
38
  const call = transport.send.bind(transport);
65
39
  const imageMarker = 'data-native-ui-snapshot';
66
40
  const sourcesChangedEvent = `${scope.namespace}:sources-change`;
@@ -78,17 +52,48 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
78
52
  return root.nodeType === 11 && 'host' in root ? root : doc;
79
53
  };
80
54
  const sheetFor = (root) => {
81
- let pair = sheets.get(root);
82
- if (!pair) {
83
- const base = doc.createElement('style'), image = doc.createElement('style');
84
- base.setAttribute('data-native-ui-snapshot-base', '');
85
- image.setAttribute(imageMarker, '');
86
- base.textContent = snapshotStyles;
87
- (root.nodeType === 11 ? root : root.head).append(base, image);
88
- pair = { base, image };
89
- sheets.set(root, pair);
90
- }
91
- return pair.image;
55
+ if (sheets.has(root))
56
+ return;
57
+ const style = doc.createElement('style');
58
+ style.setAttribute('data-native-ui-snapshot-base', '');
59
+ style.textContent = snapshotStyles + (root.nodeType === 11 ? snapshotShadowStyles : '');
60
+ (root.nodeType === 11 ? root : root.head).append(style);
61
+ sheets.set(root, style);
62
+ };
63
+ // Each shadow tree is a separate style scope. Keep these rules alongside the
64
+ // prepared image; only the marker changes during the handoff.
65
+ const finishVisibilityTransitions = (target) => {
66
+ // A visibility transition can keep the old content painted over the image.
67
+ // Do not stop layout, transform or opacity transitions owned by the renderer.
68
+ const animations = [
69
+ ...target.getAnimations({ subtree: true }),
70
+ ...[...(shadowHosts.get(target) ?? [])].flatMap((host) => host.shadowRoot?.getAnimations() ?? []),
71
+ ];
72
+ for (const animation of animations)
73
+ if ('transitionProperty' in animation && animation.transitionProperty === 'visibility')
74
+ animation.cancel();
75
+ };
76
+ const shadowHosts = new Map();
77
+ const syncShadowContent = (target) => {
78
+ const hosts = shadowHosts.get(target) ?? new Set();
79
+ shadowHosts.set(target, hosts);
80
+ const hidden = target.hasAttribute('data-native-ui-snapshot-source') || target.hasAttribute('data-native-ui-snapshot-prepared');
81
+ const visit = (element) => {
82
+ if (element.hasAttribute('data-native-ui-projection-node'))
83
+ return;
84
+ if (element.shadowRoot) {
85
+ hosts.add(element);
86
+ sheetFor(element.shadowRoot);
87
+ for (const child of element.shadowRoot.children)
88
+ visit(child);
89
+ }
90
+ for (const child of element.children)
91
+ visit(child);
92
+ };
93
+ visit(target);
94
+ for (const host of hosts)
95
+ host.toggleAttribute('data-native-ui-snapshot-content', hidden);
96
+ finishVisibilityTransitions(target);
92
97
  };
93
98
  const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
94
99
  let handoffPending = false;
@@ -96,6 +101,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
96
101
  let paintFrame = 0;
97
102
  let presentation = 0;
98
103
  let cached;
104
+ let retiring;
99
105
  let active;
100
106
  let touching = false;
101
107
  let ending = false;
@@ -108,12 +114,46 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
108
114
  let stopped = false;
109
115
  let flight = false;
110
116
  let latest;
117
+ const pruneIdleImages = () => {
118
+ const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
119
+ for (const [target, pool] of imagePool) {
120
+ const reusable = !stopped && target.isConnected && targets.has(target);
121
+ if (reusable && (active || ending || handoffPending))
122
+ continue;
123
+ const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
124
+ let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
125
+ const retained = pool.filter((image) => {
126
+ if (leasedImages.has(image) || spare-- > 0)
127
+ return true;
128
+ image.remove();
129
+ return false;
130
+ });
131
+ if (retained.length)
132
+ imagePool.set(target, retained);
133
+ else
134
+ imagePool.delete(target);
135
+ }
136
+ const liveRoots = new Set([
137
+ ...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
138
+ ...(active?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
139
+ ...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
140
+ ...[...leasedImages].map((image) => image.getRootNode()),
141
+ ...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot)),
142
+ ]);
143
+ for (const [root, pair] of sheets)
144
+ if (root.nodeType === 11 && !liveRoots.has(root)) {
145
+ pair.remove();
146
+ sheets.delete(root);
147
+ }
148
+ };
111
149
  const pendingCleanup = new Set();
112
150
  const cleanup = (action, token, afterPaint) => {
113
151
  const run = () => {
114
152
  if (!pendingCleanup.delete(run))
115
153
  return;
116
154
  void call({ action, token }).catch(() => { });
155
+ if (action === 'restored')
156
+ pruneIdleImages();
117
157
  };
118
158
  pendingCleanup.add(run);
119
159
  if (afterPaint && !stopped)
@@ -130,7 +170,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
130
170
  if (!item || item === cached || item === active)
131
171
  return;
132
172
  item.images.forEach((image) => {
133
- image.src = '';
173
+ retireImage(image);
134
174
  });
135
175
  cleanup('release', item.data.token, afterPaint);
136
176
  };
@@ -206,7 +246,19 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
206
246
  if (result.parts.some((part) => new Set(part.ids.map((id) => selected.find((candidate) => candidate.control.id === id).scope)).size !== 1))
207
247
  return;
208
248
  await bounded(Promise.all(result.parts.map(async (part) => {
209
- const image = doc.createElement('img');
249
+ const target = selected.find((candidate) => part.ids.includes(candidate.control.id)).scope.element;
250
+ let pool = imagePool.get(target);
251
+ if (!pool) {
252
+ pool = [];
253
+ imagePool.set(target, pool);
254
+ }
255
+ let image = pool.find((item) => !leasedImages.has(item));
256
+ if (!image) {
257
+ image = doc.createElement('img');
258
+ image.setAttribute('data-native-ui-projection-node', '');
259
+ pool.push(image);
260
+ }
261
+ leasedImages.add(image);
210
262
  images.push(image);
211
263
  image.src = projectionURL(part.url);
212
264
  await image.decode();
@@ -223,7 +275,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
223
275
  selected.some((candidate) => !validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource)))
224
276
  return;
225
277
  const previous = cached;
226
- cached = {
278
+ const prepared = {
227
279
  data: result,
228
280
  images,
229
281
  sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
@@ -235,12 +287,32 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
235
287
  revision,
236
288
  layout: signature(selected),
237
289
  };
290
+ try {
291
+ if (!render(prepared))
292
+ throw new Error('Projection destination is unavailable');
293
+ }
294
+ catch (error) {
295
+ // Do not publish a token whose DOM could not be installed. Restore the
296
+ // last completed presentation before retiring the failed preparation.
297
+ detach();
298
+ try {
299
+ render();
300
+ cached?.sources.forEach((source) => {
301
+ preparedOwners.add(source);
302
+ preparedSources.add(source);
303
+ });
304
+ }
305
+ catch {
306
+ abandon();
307
+ }
308
+ throw error;
309
+ }
310
+ cached = prepared;
238
311
  clearPrepared();
239
312
  cached.sources.forEach((source) => {
240
313
  preparedOwners.add(source);
241
314
  preparedSources.add(source);
242
315
  });
243
- render();
244
316
  if (callbacks.opaqueUnderlay)
245
317
  void call({ action: 'prepared', token: result.token }).catch(() => { });
246
318
  win.dispatchEvent(new Event(sourcesChangedEvent));
@@ -254,7 +326,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
254
326
  finally {
255
327
  if (!adopted)
256
328
  images.forEach((image) => {
257
- image.src = '';
329
+ retireImage(image);
258
330
  });
259
331
  if (result?.token && !adopted)
260
332
  void call({ action: 'release', token: result.token }).catch(() => { });
@@ -290,6 +362,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
290
362
  if (latest?.revision !== revision)
291
363
  generation++;
292
364
  latest = { revision, candidates };
365
+ pruneIdleImages();
293
366
  if (cached?.revision !== revision || preparedVisualRevision !== visualRevision)
294
367
  schedule();
295
368
  };
@@ -303,7 +376,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
303
376
  invalidate();
304
377
  if (active) {
305
378
  updateOffsets();
306
- followBorderAnimation();
307
379
  }
308
380
  visualRevision++;
309
381
  generation++;
@@ -320,17 +392,21 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
320
392
  }));
321
393
  const preparedTargets = new Set();
322
394
  const clearTarget = (target) => {
395
+ shadowHosts.get(target)?.forEach((host) => host.removeAttribute('data-native-ui-snapshot-content'));
323
396
  if (preparedTargets.delete(target))
324
397
  target.removeAttribute('data-native-ui-snapshot-prepared');
325
398
  target.removeAttribute('data-native-ui-snapshot-visible');
326
399
  target.removeAttribute(imageMarker);
400
+ target.removeAttribute('data-native-ui-snapshot-positioned');
327
401
  target.removeAttribute('data-native-ui-snapshot-source');
402
+ finishVisibilityTransitions(target);
403
+ shadowHosts.delete(target);
328
404
  };
329
405
  const detach = () => {
330
406
  presentation++;
331
407
  win.cancelAnimationFrame(paintFrame);
332
- win.cancelAnimationFrame(offsetFrame);
333
- offsetFrame = 0;
408
+ for (const element of offsets.keys())
409
+ element.style.setProperty('opacity', '0', 'important');
334
410
  offsets.clear();
335
411
  paintFrame = 0;
336
412
  handoffPending = false;
@@ -342,11 +418,12 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
342
418
  });
343
419
  rendered.forEach(clearTarget);
344
420
  rendered.clear();
345
- sheets.forEach(({ base, image }) => {
346
- base.remove();
347
- image.remove();
421
+ sheets.forEach((style, root) => {
422
+ if (stopped || (root.nodeType === 11 && !root.host.isConnected)) {
423
+ style.remove();
424
+ sheets.delete(root);
425
+ }
348
426
  });
349
- sheets.clear();
350
427
  };
351
428
  const abandon = (expected = active, afterPaint = ending) => {
352
429
  if (active !== expected)
@@ -370,7 +447,16 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
370
447
  return;
371
448
  }
372
449
  const image = active;
373
- detach();
450
+ presentation++;
451
+ win.cancelAnimationFrame(paintFrame);
452
+ paintFrame = 0;
453
+ handoffPending = presented = false;
454
+ image.sources.forEach((source) => {
455
+ owners.delete(source);
456
+ visibleOwners.delete(source);
457
+ });
458
+ rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
459
+ showImages(image, !!callbacks.opaqueUnderlay);
374
460
  active = undefined;
375
461
  ending = false;
376
462
  win.clearTimeout(restoreTimeout);
@@ -384,7 +470,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
384
470
  preparedOwners.add(source);
385
471
  preparedSources.add(source);
386
472
  });
387
- render();
388
473
  callbacks.wake();
389
474
  };
390
475
  const retireRestoration = () => {
@@ -394,51 +479,60 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
394
479
  };
395
480
  const targetsFor = (image) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
396
481
  const rendered = new Set();
482
+ const showImages = (image, visible) => {
483
+ for (const target of rendered) {
484
+ target.toggleAttribute('data-native-ui-snapshot-visible', visible);
485
+ syncShadowContent(target);
486
+ }
487
+ image.images.forEach((element) => element.style.setProperty('opacity', visible ? '1' : '0', 'important'));
488
+ };
397
489
  const preparedKeys = new Map();
398
490
  const offsets = new Map();
399
- let offsetFrame = 0;
400
491
  const updateOffsets = () => {
401
- for (const [target, offset] of offsets) {
402
- if (!target.isConnected)
492
+ const writes = [];
493
+ // Read all geometry before writing positions; never reset a painted image to zero to measure it.
494
+ for (const [element, placement] of offsets) {
495
+ const { target, frame } = placement;
496
+ if (!target.isConnected) {
497
+ element.remove();
403
498
  continue;
404
- const style = win.getComputedStyle(target);
405
- const left = `${offset.x - (parseFloat(style.borderLeftWidth) || 0) + target.scrollLeft}px`;
406
- const top = `${offset.y - (parseFloat(style.borderTopWidth) || 0) + target.scrollTop}px`;
407
- if (offset.style.left !== left)
408
- offset.style.setProperty('left', left, 'important');
409
- if (offset.style.top !== top)
410
- offset.style.setProperty('top', top, 'important');
411
- }
412
- };
413
- const followBorderAnimation = () => {
414
- if (offsetFrame || !active)
415
- return;
416
- const movingBorder = [...offsets.keys()].some((target) => target
417
- .getAnimations?.()
418
- .some((animation) => animation.playState === 'running' &&
419
- animation.effect
420
- ?.getKeyframes()
421
- .some((frame) => ['borderLeftWidth', 'borderTopWidth', 'borderWidth'].some((property) => property in frame))));
422
- if (movingBorder)
423
- offsetFrame = win.requestAnimationFrame(() => {
424
- offsetFrame = 0;
425
- updateOffsets();
426
- followBorderAnimation();
499
+ }
500
+ const parent = target.shadowRoot ?? target;
501
+ if (element.parentNode !== parent)
502
+ parent.insertBefore(element, null);
503
+ const box = target.getBoundingClientRect();
504
+ const rect = element.getBoundingClientRect();
505
+ const width = parseFloat(element.style.width) || frame.width;
506
+ const height = parseFloat(element.style.height) || frame.height;
507
+ const sx = rect.width / width || 1, sy = rect.height / height || 1;
508
+ const initial = placement.scale ?? (placement.scale = { x: sx, y: sy });
509
+ writes.push({
510
+ element,
511
+ left: (parseFloat(element.style.left) || 0) + (box.x + (frame.x * sx) / initial.x - rect.x) / sx,
512
+ top: (parseFloat(element.style.top) || 0) + (box.y + (frame.y * sy) / initial.y - rect.y) / sy,
513
+ width: frame.width / initial.x,
514
+ height: frame.height / initial.y,
427
515
  });
516
+ }
517
+ for (const { element, ...values } of writes)
518
+ for (const [property, value] of Object.entries(values)) {
519
+ const next = `${value}px`;
520
+ if (element.style.getPropertyValue(property) !== next)
521
+ element.style.setProperty(property, next, 'important');
522
+ }
428
523
  };
429
- const render = () => {
430
- const image = active ?? cached;
524
+ const render = (image = active ?? cached) => {
431
525
  if (!image || ending)
432
- return;
526
+ return false;
433
527
  if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
434
528
  invalidate();
435
- return;
529
+ return false;
436
530
  }
437
531
  const targets = targetsFor(image);
438
532
  if (active) {
439
533
  if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
440
534
  abandon(active, false);
441
- return;
535
+ return false;
442
536
  }
443
537
  const next = new Set();
444
538
  for (const [index, source] of image.targets.entries()) {
@@ -448,7 +542,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
448
542
  continue;
449
543
  target.setAttribute(imageMarker, key);
450
544
  target.setAttribute('data-native-ui-snapshot-source', '');
451
- target.toggleAttribute('data-native-ui-snapshot-visible', presented);
452
545
  next.add(target);
453
546
  }
454
547
  for (const target of rendered)
@@ -456,83 +549,65 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
456
549
  clearTarget(target);
457
550
  rendered.clear();
458
551
  next.forEach((target) => rendered.add(target));
459
- return;
552
+ showImages(image, presented);
553
+ return true;
460
554
  }
555
+ imagePool.forEach((pool) => pool.forEach((element) => {
556
+ if (!image.images.includes(element)) {
557
+ element.style.setProperty('display', 'none', 'important');
558
+ element.removeAttribute('data-native-ui-projection-image');
559
+ }
560
+ }));
461
561
  preparedKeys.clear();
462
562
  offsets.clear();
463
563
  rendered.forEach(clearTarget);
464
564
  rendered.clear();
465
- // Reuse the image stylesheet. Presentation states belong to the runtime's
466
- // permanent CSS, which survives cache replacement and image retirement.
467
- const groups = new Map();
468
565
  for (const target of new Set(targets)) {
469
566
  if (!target)
470
567
  continue;
471
568
  rendered.add(target);
472
- const sheet = sheetFor(rootFor(target));
473
- let group = groups.get(sheet);
474
- if (!group) {
475
- group = { rules: [], positions: [] };
476
- groups.set(sheet, group);
477
- }
478
- const { rules, positions } = group;
479
- // A decoded clone stays concealed even if this cache is invalidated before
480
- // the projection adapter publishes its next Native/Web ownership state.
481
- if (target !== image.targets[targets.indexOf(target)] && !target.hasAttribute('data-native-ui-snapshot-prepared')) {
569
+ sheetFor(rootFor(target));
570
+ const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
571
+ const key = `${imageIdentity}-${indices[0]}`;
572
+ target.setAttribute(imageMarker, key);
573
+ if (target !== image.targets[indices[0]]) {
482
574
  target.setAttribute('data-native-ui-snapshot-prepared', '');
483
575
  preparedTargets.add(target);
484
576
  }
485
- target.toggleAttribute('data-native-ui-snapshot-visible', !!active || !!callbacks.opaqueUnderlay);
486
- if (active)
487
- target.setAttribute('data-native-ui-snapshot-source', '');
488
- const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
489
- const style = win.getComputedStyle(target);
490
- {
491
- const space = coordinateSpace(target, win, style);
492
- indices.forEach((index) => {
493
- const part = image.data.parts[index];
494
- if (callbacks.opaqueUnderlay && image.frames[index])
495
- return;
496
- image.frames[index] = {
497
- x: callbacks.opaqueUnderlay ? 0 : (part.x - space.x) / space.scaleX,
498
- y: callbacks.opaqueUnderlay ? 0 : (part.y - space.y) / space.scaleY,
499
- width: part.width / space.scaleX,
500
- height: part.height / space.scaleY,
501
- };
502
- });
577
+ target.toggleAttribute('data-native-ui-snapshot-visible', !!callbacks.opaqueUnderlay);
578
+ syncShadowContent(target);
579
+ const box = target.getBoundingClientRect();
580
+ target.toggleAttribute('data-native-ui-snapshot-positioned', win.getComputedStyle(target).position === 'static');
581
+ const parent = target.shadowRoot ?? target;
582
+ for (const index of [...indices].reverse()) {
583
+ const element = image.images[index];
584
+ const part = image.data.parts[index];
585
+ const frame = image.frames[index] ?? {
586
+ x: callbacks.opaqueUnderlay ? 0 : part.x - box.x,
587
+ y: callbacks.opaqueUnderlay ? 0 : part.y - box.y,
588
+ width: part.width,
589
+ height: part.height,
590
+ };
591
+ image.frames[index] = frame;
592
+ preparedKeys.set(image.targets[index], key);
593
+ element.inert = true;
594
+ element.setAttribute('aria-hidden', 'true');
595
+ element.setAttribute('data-native-ui-projection-image', key);
596
+ element.style.cssText =
597
+ 'all:initial!important;position:absolute!important;display:block!important;visibility:visible!important;pointer-events:none!important;image-rendering:pixelated!important;will-change:opacity!important;opacity:0!important;';
598
+ element.style.setProperty('width', `${frame.width}px`, 'important');
599
+ element.style.setProperty('height', `${frame.height}px`, 'important');
600
+ element.style.setProperty('left', '0px', 'important');
601
+ element.style.setProperty('top', '0px', 'important');
602
+ element.style.setProperty('opacity', callbacks.opaqueUnderlay ? '1' : '0', 'important');
603
+ if (element.parentNode !== parent)
604
+ parent.insertBefore(element, null);
605
+ offsets.set(element, { target, frame });
503
606
  }
504
- const frames = indices.map((index) => image.frames[index]);
505
- const x = Math.min(...frames.map((frame) => frame.x));
506
- const y = Math.min(...frames.map((frame) => frame.y));
507
- const right = Math.max(...frames.map((frame) => frame.x + frame.width));
508
- const bottom = Math.max(...frames.map((frame) => frame.y + frame.height));
509
- const left = (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft;
510
- const top = (parseFloat(style.borderTopWidth) || 0) - target.scrollTop;
511
- const key = `${imageIdentity}-${indices[0]}`;
512
- indices.forEach((index) => preparedKeys.set(image.targets[index], key));
513
- target.setAttribute(imageMarker, key);
514
- rules.push(`[${imageMarker}="${key}"][data-native-ui-snapshot-visible] { ${style.position === 'static' ? 'position:relative!important;' : ''} }`);
515
- const backgrounds = indices.map((index) => {
516
- const frame = image.frames[index];
517
- return `url(${JSON.stringify(image.images[index].src)}) ${frame.x - x}px ${frame.y - y}px / ${frame.width}px ${frame.height}px no-repeat`;
518
- });
519
- const declaration = doc.createElement('span').style;
520
- declaration.cssText = `left:${x - left}px;top:${y - top}px;width:${right - x}px;height:${bottom - y}px;background:${backgrounds.join(',')};`;
521
- for (const property of Array.from(declaration))
522
- declaration.setProperty(property, declaration.getPropertyValue(property), 'important');
523
- // Outrank every manager's shared reset, even when another manager starts later.
524
- rules.push(`[${imageMarker}="${key}"][${imageMarker}][${imageMarker}]::before { ${declaration.cssText} }`);
525
- positions.push({ target, x, y });
526
607
  }
527
608
  image.parents = image.scopes.map((scope) => scope.element.parentNode);
528
- for (const [sheet, { rules, positions }] of groups) {
529
- sheet.textContent = rules.join('\n');
530
- positions.forEach(({ target, x, y }, index) => offsets.set(target, {
531
- x,
532
- y,
533
- style: sheet.sheet.cssRules[index * 2 + 1].style,
534
- }));
535
- }
609
+ updateOffsets();
610
+ return true;
536
611
  };
537
612
  const begin = () => {
538
613
  retireRestoration();
@@ -564,10 +639,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
564
639
  image.sources.forEach((source) => {
565
640
  owners.add(source);
566
641
  });
567
- rendered.forEach((target) => {
568
- target.setAttribute('data-native-ui-snapshot-source', '');
569
- target.toggleAttribute('data-native-ui-snapshot-visible', presented);
570
- });
642
+ rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
643
+ showImages(image, presented);
571
644
  // Reconciliation may continue after one frame even if the optional bridge reply
572
645
  // is delayed. The ordered native transport processes cover before that update.
573
646
  paintFrame = win.requestAnimationFrame(() => {
@@ -593,7 +666,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
593
666
  image.sources.forEach((source) => visibleOwners.add(source));
594
667
  render();
595
668
  updateOffsets();
596
- followBorderAnimation();
597
669
  }
598
670
  win.cancelAnimationFrame(paintFrame);
599
671
  paintFrame = win.requestAnimationFrame(() => {
@@ -624,6 +696,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
624
696
  }
625
697
  if (callbacks.blocked())
626
698
  return;
699
+ updateOffsets();
627
700
  if (callbacks.transient) {
628
701
  const image = active;
629
702
  const targets = targetsFor(image);
@@ -632,15 +705,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
632
705
  return;
633
706
  }
634
707
  const parts = targets.map((target, index) => {
635
- const space = coordinateSpace(target, win);
636
- const frame = image.frames[index];
637
- return {
638
- ...image.data.parts[index],
639
- x: space.x + frame.x * space.scaleX,
640
- y: space.y + frame.y * space.scaleY,
641
- width: frame.width * space.scaleX,
642
- height: frame.height * space.scaleY,
643
- };
708
+ const rect = image.images[index].getBoundingClientRect();
709
+ return { ...image.data.parts[index], x: rect.x, y: rect.y, width: rect.width, height: rect.height };
644
710
  });
645
711
  if (parts.some((part, index) => ['x', 'y', 'width', 'height'].some((key) => part[key] !== image.data.parts[index][key]))) {
646
712
  if (callbacks.opaqueUnderlay)
@@ -672,12 +738,44 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
672
738
  finishRestoration();
673
739
  schedule();
674
740
  };
675
- const invalidate = () => {
741
+ const invalidate = (nativeHidden = false) => {
676
742
  pendingCleanup.forEach((run) => run());
677
743
  generation++;
678
744
  win.clearTimeout(timer);
679
745
  const previous = cached;
680
746
  cached = undefined;
747
+ if (nativeHidden || stopped)
748
+ retiring = undefined;
749
+ if (callbacks.retainPreparedUntilRelease && !active && !nativeHidden && !stopped) {
750
+ if (previous) {
751
+ const pending = {};
752
+ retiring = pending;
753
+ void bounded(call({ action: 'release', token: previous.data.token }))
754
+ .catch(() => { })
755
+ .finally(() => {
756
+ previous.images.forEach((image) => {
757
+ retireImage(image);
758
+ });
759
+ if (retiring !== pending)
760
+ return;
761
+ retiring = undefined;
762
+ if (cached || active)
763
+ return;
764
+ // Another session can already own the same host; retire only our own DOM markers.
765
+ for (const target of rendered) {
766
+ if (target.getAttribute(imageMarker) !== preparedKeys.get(target)) {
767
+ rendered.delete(target);
768
+ preparedTargets.delete(target);
769
+ }
770
+ }
771
+ detach();
772
+ preparedKeys.clear();
773
+ win.dispatchEvent(new Event(sourcesChangedEvent));
774
+ });
775
+ }
776
+ if (retiring)
777
+ return;
778
+ }
681
779
  dispose(previous);
682
780
  if (active)
683
781
  abandon(active, false);
@@ -729,10 +827,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
729
827
  },
730
828
  consider,
731
829
  changed,
732
- moved: () => {
733
- updateOffsets();
734
- followBorderAnimation();
735
- },
830
+ moved: updateOffsets,
736
831
  get token() {
737
832
  return ending ? undefined : active?.data.token;
738
833
  },
@@ -750,6 +845,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
750
845
  async destroy() {
751
846
  stopped = true;
752
847
  invalidate();
848
+ imagePool.forEach((pool) => pool.forEach((image) => image.remove()));
849
+ imagePool.clear();
753
850
  await Promise.allSettled([
754
851
  bounded(Promise.resolve().then(() => listener?.remove())),
755
852
  bounded(Promise.resolve().then(() => eventListener?.remove())),
@@ -828,7 +925,7 @@ export const createWebProjectionBatch = async (transport, options) => {
828
925
  end: () => engine?.end(),
829
926
  updated: (token, ids, revision) => engine?.updated(token, ids, revision),
830
927
  retireRestoration: () => engine?.retireRestoration(),
831
- invalidate: () => engine?.invalidate(),
928
+ invalidate: (nativeHidden) => engine?.invalidate(nativeHidden),
832
929
  async destroy() {
833
930
  stopped = true;
834
931
  await initializing;