@webview-native-ui/engine 0.0.6 → 0.0.7

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.
Files changed (45) hide show
  1. package/README.md +19 -12
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/native/session.d.ts +5 -0
  5. package/dist/native/session.js +83 -14
  6. package/dist/native/updates.d.ts +16 -0
  7. package/dist/native/updates.js +108 -0
  8. package/dist/native/values.d.ts +5 -0
  9. package/dist/native/values.js +27 -0
  10. package/dist/projection/surface.d.ts +35 -0
  11. package/dist/projection/surface.js +81 -0
  12. package/dist/projection/surfaces.d.ts +20 -0
  13. package/dist/projection/surfaces.js +116 -0
  14. package/docs/README.md +70 -0
  15. package/docs/advanced/README.md +20 -0
  16. package/docs/advanced/android-placement.md +48 -0
  17. package/docs/advanced/native-handoff.md +89 -0
  18. package/docs/advanced/renderer.md +148 -0
  19. package/docs/advanced/update-queue.md +58 -0
  20. package/docs/advanced/web-projection.md +88 -0
  21. package/docs/assets/ios-native-capture-comparison.png +0 -0
  22. package/docs/concepts.md +97 -0
  23. package/docs/configuration/README.md +29 -0
  24. package/docs/configuration/capture.md +27 -0
  25. package/docs/configuration/events.md +35 -0
  26. package/docs/configuration/ionic-overlays.md +65 -0
  27. package/docs/configuration/values.md +125 -0
  28. package/docs/cordova.md +1 -1
  29. package/docs/{capacitor-plugin.md → getting-started/capacitor.md} +45 -47
  30. package/docs/{cordova-plugin.md → getting-started/cordova.md} +32 -15
  31. package/docs/getting-started/initialization.md +79 -0
  32. package/docs/maintenance.md +14 -2
  33. package/docs/{projection-containers.md → reference/containers.md} +6 -2
  34. package/docs/reference/platforms.md +137 -0
  35. package/docs/reference/projection.md +134 -0
  36. package/docs/reference/state.md +39 -0
  37. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +12 -12
  38. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +68 -7
  39. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +10 -10
  40. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +74 -39
  41. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -3
  42. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +2 -0
  43. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +9 -7
  44. package/package.json +9 -2
  45. package/docs/projection.md +0 -368
@@ -0,0 +1,116 @@
1
+ /** Owns asynchronous projection creation and disposal for DOM-backed surfaces. */
2
+ export const createWebProjectionSurfaces = (options = {}) => {
3
+ const entries = new Map();
4
+ let stopped = false;
5
+ const values = () => [...entries.values()].flatMap((entry) => (entry.surface ? [entry.surface] : []));
6
+ let doc;
7
+ let listeners;
8
+ let motionFrame = 0;
9
+ const invalidate = () => values().forEach((surface) => surface.invalidate());
10
+ const changed = (owner) => values().forEach((surface) => surface.changed(owner));
11
+ const observe = (owner) => {
12
+ const ownerDocument = owner.nodeType === 9 ? owner : owner.ownerDocument;
13
+ if (doc && doc !== ownerDocument)
14
+ throw new TypeError('Projection surfaces must share one Document');
15
+ if (doc)
16
+ return;
17
+ doc = ownerDocument;
18
+ const win = doc.defaultView;
19
+ listeners = new win.AbortController();
20
+ const subscription = { signal: listeners.signal };
21
+ win.addEventListener('resize', invalidate, subscription);
22
+ win.addEventListener('orientationchange', invalidate, subscription);
23
+ doc.addEventListener('visibilitychange', () => {
24
+ if (doc.hidden)
25
+ invalidate();
26
+ }, subscription);
27
+ const appearance = (event) => {
28
+ const target = event.target instanceof win.Element ? event.target : undefined;
29
+ if (target?.closest('style[data-native-ui-snapshot], [data-native-ui-projection-node]') || (target && options.ignoreTarget?.(target)))
30
+ return;
31
+ if (target?.hasAttribute('data-native-ui-snapshot') && event.pseudoElement)
32
+ return;
33
+ changed();
34
+ };
35
+ for (const name of ['load', 'transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'])
36
+ doc.addEventListener(name, appearance, { ...subscription, capture: name === 'load' });
37
+ };
38
+ const followMotion = (moving, target = (source) => source) => {
39
+ if (motionFrame || stopped || !moving.size || !doc?.defaultView)
40
+ return;
41
+ // Ancestor transforms already carry the image. Only the host's own motion needs rebasing.
42
+ const surfaces = values().filter((surface) => (surface.token || surface.restoring) && surface.owners.some((owner) => moving.has(target(owner))));
43
+ if (!surfaces.length)
44
+ return;
45
+ motionFrame = doc.defaultView.requestAnimationFrame(() => {
46
+ motionFrame = 0;
47
+ surfaces.forEach((surface) => surface.moved());
48
+ followMotion(moving, target);
49
+ });
50
+ };
51
+ const remove = async (owner) => {
52
+ const entry = entries.get(owner);
53
+ if (!entry)
54
+ return;
55
+ entries.delete(owner);
56
+ if (entry.surface)
57
+ await entry.surface.destroy();
58
+ // A failed creation has nothing to dispose; ensure() reports that error.
59
+ else
60
+ await entry.ready.catch(() => { });
61
+ };
62
+ return {
63
+ get: (owner) => entries.get(owner)?.surface,
64
+ keys: () => entries.keys(),
65
+ values,
66
+ changed,
67
+ invalidate,
68
+ followMotion,
69
+ end: () => values().forEach((surface) => surface.end()),
70
+ consider: (...args) => values().forEach((surface) => surface.consider(...args)),
71
+ updated: (...args) => values().forEach((surface) => surface.updated(...args)),
72
+ get restoring() {
73
+ return values().some((surface) => surface.restoring);
74
+ },
75
+ get handoffPending() {
76
+ return values().some((surface) => surface.handoffPending);
77
+ },
78
+ ensure(owner, create) {
79
+ if (stopped || !owner.isConnected)
80
+ return Promise.resolve(undefined);
81
+ observe(owner);
82
+ const existing = entries.get(owner);
83
+ if (existing)
84
+ return existing.ready;
85
+ const entry = {
86
+ ready: Promise.resolve()
87
+ .then(create)
88
+ .then(async (surface) => {
89
+ if (stopped || entries.get(owner) !== entry || !owner.isConnected) {
90
+ if (entries.get(owner) === entry)
91
+ entries.delete(owner);
92
+ await surface.destroy();
93
+ return undefined;
94
+ }
95
+ entry.surface = surface;
96
+ return surface;
97
+ })
98
+ .catch((error) => {
99
+ if (entries.get(owner) === entry)
100
+ entries.delete(owner);
101
+ throw error;
102
+ }),
103
+ };
104
+ entries.set(owner, entry);
105
+ return entry.ready;
106
+ },
107
+ delete: remove,
108
+ async destroy() {
109
+ stopped = true;
110
+ listeners?.abort();
111
+ if (motionFrame)
112
+ doc?.defaultView?.cancelAnimationFrame(motionFrame);
113
+ await Promise.all([...entries.keys()].map(remove));
114
+ },
115
+ };
116
+ };
package/docs/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # Documentation
2
+
3
+ Start with the smallest integration that meets your component's needs. Basic
4
+ registration is enough for a single native view; optional settings adapt it to
5
+ changing content and application lifecycle. Advanced APIs are a separate route for
6
+ renderers that already own batching or placement.
7
+
8
+ ## Choose by the problem you need to solve
9
+
10
+ | Case | Start here | Why |
11
+ | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
12
+ | A Web modal must cover a plugin's native tab bar | [Basic registration](getting-started/initialization.md), then the framework integration guide | Register the view and its DOM host once; the Engine owns capture and restoration. |
13
+ | A hidden fallback returns with the old toggle selection | [Value bindings](configuration/values.md) | Reflect current native data on the fallback host, independently of where it is displayed. |
14
+ | Background toolbar buttons and foreground modal buttons need different handoff lifetimes | [Renderer surfaces](advanced/renderer.md) | Separate owners so closing one surface cannot restore another's controls. |
15
+ | An existing renderer overlaps updates or loses a native acknowledgement | [Update queue](advanced/update-queue.md) | Serialize fresh-state passes and reconcile the connection without replaying user actions. |
16
+
17
+ The full reading order follows. Skip advanced APIs unless your renderer owns the
18
+ work described in their cases.
19
+
20
+ ## 1. Understand the approach
21
+
22
+ [Concepts and limits](concepts.md) explains what projection makes possible, how it
23
+ differs from live native UI and Web fallback, and which responsibilities stay with
24
+ the plugin.
25
+
26
+ ## 2. Initialize and integrate
27
+
28
+ Read [basic initialization](getting-started/initialization.md) for the minimum call
29
+ and required inputs. Then choose one platform-framework guide:
30
+
31
+ - [Capacitor plugin](getting-started/capacitor.md): dependencies, native plugin hooks, event forwarding and teardown.
32
+ - [Cordova plugin](getting-started/cordova.md): the same registration with Cordova startup and resource handling.
33
+
34
+ Neither route requires a second application-side Engine wrapper or the advanced
35
+ renderer APIs.
36
+
37
+ ## 3. Add optional behavior
38
+
39
+ [Registration settings](configuration/README.md) lists defaults and the situations
40
+ that require each option. Read only the relevant detail:
41
+
42
+ - [Notifications](configuration/events.md): tell the Engine when appearance or presentation changes.
43
+ - [Value bindings](configuration/values.md): keep the fallback host's component data aligned with native state.
44
+ - [Capture timing](configuration/capture.md): prepare pixels after native appearance settles.
45
+ - [Ionic overlays](configuration/ionic-overlays.md): connect Ionic coverage with one shared application initializer.
46
+
47
+ ## 4. Use advanced APIs when you own a renderer
48
+
49
+ [Choose an advanced API](advanced/README.md) before adding one. These guides address
50
+ different existing renderer needs; they are not a sequence of required setup steps.
51
+
52
+ - [Page and modal surfaces](advanced/renderer.md)
53
+ - [Low-level Web projection and batches](advanced/web-projection.md)
54
+ - [Native handoff and scroll bindings](advanced/native-handoff.md)
55
+ - [Deferred Android placement](advanced/android-placement.md)
56
+ - [Update serialization and recovery](advanced/update-queue.md)
57
+
58
+ ## Reference
59
+
60
+ Use these to check contracts and supported behavior while implementing either route:
61
+
62
+ - [Projection contract and lifetime](reference/projection.md)
63
+ - [HTML and Ionic container support](reference/containers.md)
64
+ - [Observable presentation intent](reference/state.md)
65
+ - [Capture and platform limits](reference/platforms.md)
66
+
67
+ ## Contributing
68
+
69
+ [Development and releases](maintenance.md) covers repository structure, local
70
+ verification, CI and publication. It is not required for consuming the Engine.
@@ -0,0 +1,20 @@
1
+ # Advanced APIs
2
+
3
+ The basic registration already owns measurement, capture and handoff acknowledgement.
4
+ Do not add a second projection manager for the same host. Use the following APIs
5
+ when your renderer already owns the corresponding work and needs to coordinate it:
6
+
7
+ | Need | API / guide |
8
+ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | Apply deferred native placement just before an opaque Android view returns | `beforeNativeReveal`, `updatePlacement`, `isProjecting` and `bindProjectionPlacement`; see [Android placement handoff](android-placement.md#final-placement-when-returning-from-an-opaque-image-android). Both callbacks default to no custom placement work. |
10
+ | Manage independent pages or modals containing multiple native controls | `createWebProjectionSurface` / `createWebProjectionSurfaces`; see the [renderer guide](renderer.md). |
11
+ | Coordinate several fallback containers in one native transaction | `createWebProjectionBatch`; see [batch handoff](web-projection.md#multiple-fallbacks-in-one-native-transaction). |
12
+ | Fit an existing ownership model that surface helpers cannot express | `createWebProjectionScope` / `createWebProjection`; see [low-level projection](web-projection.md). |
13
+ | Serialize replaceable state updates and recover a lost acknowledgement | `createNativeUpdateQueue`; see [update scheduling and recovery](update-queue.md). |
14
+ | Implement the native bridge for a custom renderer | Snapshot-manager `handle` methods; see [native handoff](native-handoff.md). |
15
+
16
+ These are integration choices, not additional steps to complete a normal
17
+ `NativeUIProjection` setup. Keep business actions, SDK interaction and application
18
+ notifications in the consuming plugin.
19
+
20
+ [Documentation overview](../README.md)
@@ -0,0 +1,48 @@
1
+ # Deferred placement on Android
2
+
3
+ Use this only when an existing renderer defers native placement while an opaque
4
+ image owns presentation. Basic registrations do not require these callbacks.
5
+
6
+ For example, an opaque native advertisement moves through a scrolling feed.
7
+ Once its image follows the DOM, repeatedly laying out the hidden native view adds
8
+ work without changing visible pixels. Defer that placement and apply the latest
9
+ position before revealing native content, so it does not jump back to the old
10
+ location. Keep ordinary placement tracking if the view cannot use an opaque image.
11
+
12
+ Android VIEW consumers can read `isProjecting` on the UI thread after a successful
13
+ cover to defer same-size native placement work. Keep only the latest accepted placement;
14
+ continue validating sequence/generation, and process hide, resize and teardown immediately.
15
+ Supply `beforeNativeReveal` to apply that pending placement synchronously before normal
16
+ restoration, failure recovery, or active-image release reveals native pixels. The engine
17
+ clears `isProjecting` before invoking the callback, so applying placement must not defer
18
+ again. The callback may run without pending work and must be idempotent. Clear consumer
19
+ pending work on hide/destroy and validate the loaded View identity before applying it.
20
+ The callback does not replace content/size invalidation or JavaScript geometry tracking.
21
+
22
+ ### Final placement when returning from an opaque image (Android)
23
+
24
+ A plugin that normally measures and posts native placement on every scroll can opt
25
+ into a final placement handoff. Pass `updatePlacement: (JSONObject) -> Unit` to its
26
+ Android `NativeUIProjection` registration and bind its Web host with
27
+ `bindProjectionPlacement(element, measure, invalidate)`.
28
+
29
+ - `binding.projected` becomes true only after native confirms the image handoff.
30
+ While true, the plugin can reuse that host's last placement in full placement
31
+ batches and skip measuring it. Other hosts continue their ordinary updates.
32
+ - `measure()` returns a JSON object synchronously. The engine includes it in the
33
+ existing restore request (or an active-image recovery request). Native validates
34
+ the active image token and View identity, calls `updatePlacement`, then reveals
35
+ native and acknowledges its draw. No extra bridge round trip is introduced.
36
+ - The plugin must validate its own identity, generation and per-host sequence in
37
+ `updatePlacement`. Throw to reject a stale or invalid placement. A final update
38
+ for one host must not cause updates for another host to be dropped.
39
+ - `invalidate()` is called after a failed handoff request. Clear optimistic
40
+ placement/deduplication caches and schedule ordinary placement reconciliation.
41
+ - Dispose the binding when detaching a host. Session ownership prevents an old
42
+ registration's delayed acknowledgement from clearing a new registration's
43
+ projection state. Pause, author visibility and layout changes must still use
44
+ the plugin's normal visibility/invalidation paths.
45
+
46
+ This opt-in does not change registrations without `updatePlacement`. Android rejects
47
+ `updatePlacement` unless `captureMode = CaptureMode.VIEW`; live video and other ineligible captures
48
+ continue native placement tracking under `failedFallback = NATIVE`.
@@ -0,0 +1,89 @@
1
+ # Native handoff for custom renderers
2
+
3
+ An existing renderer should forward its ordered `WebProjectionRequest` messages
4
+ to the native snapshot manager's `handle` method. This keeps request validation,
5
+ CSS-to-native coordinate conversion, cached image handoff and copy retirement in
6
+ the Engine. The bridge remains the plugin's own Capacitor or Cordova method; the
7
+ Engine does not depend on either framework's call object.
8
+
9
+ For example, a renderer already sends toolbar buttons as one native update and
10
+ knows which views belong to each button group. Forward image handoff messages to
11
+ `handle` instead of reimplementing coordinate conversion and copy retirement for
12
+ each group. Keep the group's capture eligibility and ordinary layout in that
13
+ renderer: the Engine cannot infer which sibling views may safely hide together.
14
+
15
+ Call view and snapshot lifecycle APIs on the main/UI thread. Supply two renderer
16
+ callbacks: `frames(ids, revision)` returns current eligible bounds, and `views(ids)`
17
+ returns the complete native views that can safely be hidden together. Return empty
18
+ bounds or `nil` / `null` when that operation is unavailable. Bounds use **points
19
+ relative to the WebView on iOS** and **physical pixels relative to the WebView on
20
+ Android**. The Engine checks that measured IDs exactly match the request.
21
+
22
+ ```swift
23
+ snapshots.handle(request, in: webView,
24
+ frames: { ids, revision in renderer.measure(ids, at: revision) },
25
+ views: { ids in renderer.viewsAllowedToHide(ids) },
26
+ complete: reply)
27
+ ```
28
+
29
+ ```kotlin
30
+ snapshots.handle(request, activity.window, webView,
31
+ frames = { ids, revision -> renderer.measure(ids, revision) },
32
+ views = { ids -> renderer.viewsAllowedToHide(ids) },
33
+ complete = { result ->
34
+ result.fold(onSuccess = reply, onFailure = reject)
35
+ })
36
+ ```
37
+
38
+ `renderer` and `reply` above are the plugin's existing layout code and bridge
39
+ completion callback. On Android, forward a failed `Result` to the bridge's
40
+ rejection callback (`reject` above); Web uses that failure to abandon the image
41
+ and reconcile presentation. Do not acknowledge a failed drawing boundary as
42
+ success. `frames` may run repeatedly while geometry settles; it must
43
+ recheck the requested revision and capture eligibility. It must not mutate layout.
44
+ If multiple controls share a native container, `views` must reject a partial set
45
+ that would hide uncaptured controls. The Engine cannot infer that relationship.
46
+ The renderer also keeps its existing native interaction notifications.
47
+
48
+ `cover` installs prepared pixels before hiding those views. Destination parts are
49
+ validated against the cached token and used for both cover and restore, so moving
50
+ a prepared surface does not reuse an old position. Invalid or unavailable handoffs
51
+ return `shown: false`. `painted` and `restored` retire only the corresponding copy;
52
+ Android waits for Web drawing readiness. `release` and `reset` retire owned
53
+ resources. No capture starts in the cover or restore path.
54
+
55
+ The renderer still performs its ordinary native update between `restore` and the
56
+ accepted update acknowledgement. Before that update, call `restoreControls()` on
57
+ iOS or `restoreViews()` on Android to undo only the Engine's hiding changes. This
58
+ is not a command to lay out or reveal arbitrary plugin content. Keep the normal
59
+ visibility and placement policy in the renderer.
60
+
61
+ ### Direct snapshot primitives
62
+
63
+ Use these only when your integration owns native placement during handoff. `NativeUIProjection` uses the same operations with registration-specific placement and opaque-view policies. Do not duplicate conversion or copy retirement when forwarding requests to `handle`.
64
+
65
+ - Convert destination `parts` from CSS pixels using `webView.bounds.width / viewportWidth` on iOS or `webView.width / viewportWidth` on Android. Pass frames to `cover` and `restore`; absent cover parts retain captured placement. Neither operation captures new pixels.
66
+ - `transient: true` separates a moving foreground surface from the background's `coveringToken`. Reuse pixels while source sizes and positions relative to their scope remain unchanged; movement within a fixed scope invalidates the layout.
67
+ - Retire the entry copy after Web drawing readiness: `retireCopy(token)` on iOS, `retireCopyAfterWebDraw` on Android.
68
+ - For return, call `restore`, undo Engine hiding with `restoreControls()` / `restoreViews()`, and apply normal native layout. Retire the return copy after the accepted native update and Web drawing readiness, using `retireCopy` with `entry: false` / `entry = false`. Android can forward `painted` and `restored` to `handle` to reuse readiness and rejection handling.
69
+ - Release tokens with `release(token)`. Use `reset()` only for owner teardown, and Android `close()` to dispose the encoder. Never reset another surface's captures.
70
+
71
+ Missing captures or placement use the renderer's ordinary fallback. See the [Web request contract](web-projection.md#multiple-fallbacks-in-one-native-transaction) for payloads.
72
+
73
+ Use `NativeUICoveredSnapshots.observe` to notify the JS listener when interaction starts/ends. On iOS, captures rasterize the visible window with `drawHierarchy(in:afterScreenUpdates:)`, including the background under the clipped native component. Screen snapshot views are display replicas and are not used as bitmap sources. UIKit rasterization can differ from the live compositor for materials such as Liquid Glass; validate the appearance on physical devices with the backgrounds your plugin supports. Cache validity therefore includes layout and the plugin's visual-change notifications. The engine does not capture continuously or guarantee every possible renderer can be captured.
74
+
75
+ ## Native scroll tracking
76
+
77
+ A native control inside a Web scrolling list would otherwise need new placement
78
+ messages as the list moves. Use these bindings only when the renderer already
79
+ owns scroll reconciliation: a uniquely matched iOS overflow scroll view can
80
+ carry native content with it. Nested or ambiguous scrolling must use the ordinary
81
+ fallback instead of attaching content to the wrong scroll owner.
82
+
83
+ `readScrollPlacement(element, owner, identify)` measures one untransformed overflow owner. `createScrollBindings()` validates reuse and detects movement while acquisition is awaiting ACK. Keep `reuse` out of layout-deduplication signatures; it describes acknowledgement state, not layout.
84
+
85
+ On iOS, `NativeUIScrollBindings.replace` groups sources by owner and binds only an unambiguous matching `UIScrollView`. `update()` applies positions and clipping without rebuilding content. `NativeUIClippedView` preserves full content dimensions while restricting visible pixels and hit testing.
86
+
87
+ An established binding rejected by native loses ownership and may be freshly acquired through the **same** update/ACK path. A failed fresh acquisition stays on the fallback until a relevant layout/scroll-end event. Never add an immediate retry loop or replay user actions. A frame/props change does not recalibrate an established coordinate origin.
88
+
89
+ This uses WKWebView's internal overflow hierarchy without private class names. Missing/ambiguous views, nested scrolling, document scrolling, unsupported transforms, shaped clipping, or a moving unconfirmed owner cannot be bound. Do not claim support solely because a `UIScrollView` exists. A native view may intercept gestures that begin inside it; routing those gestures and SDK accessibility behavior remain renderer-specific.
@@ -0,0 +1,148 @@
1
+ # Advanced: coordinate pages and modals in an existing renderer
2
+
3
+ Here, a **renderer** means the part of your plugin that creates native views and
4
+ updates their layout and appearance. Use this guide when it manages multiple native
5
+ controls, batches their updates, and knows which page or modal owns them. The
6
+ Engine takes over image preparation, scope reuse and handoff cleanup so those
7
+ concerns do not need separate implementations for every overlay.
8
+
9
+ For a single native view, start with [Capacitor](../getting-started/capacitor.md) or
10
+ [Cordova](../getting-started/cordova.md) registration instead. For the shared model and limits,
11
+ read [concepts](../concepts.md). The APIs below are for an existing renderer, not
12
+ additional steps required by those plugin guides. See [native initialization](../getting-started/initialization.md)
13
+ for the basic API and its optional settings before choosing this route.
14
+
15
+ ## Group controls by the lifecycle they share
16
+
17
+ A **surface** groups controls that must hand off together, such as the buttons on
18
+ a page or modal. It is not necessarily one native view or image.
19
+ `createWebProjectionSurface` manages their prepared images and fallback scopes;
20
+ `createWebProjectionSurfaces` manages the lifetime of multiple such surfaces.
21
+
22
+ Separate groups matter because covering a background page does not necessarily
23
+ cover the foreground modal's controls. Sharing one lifecycle would make those
24
+ independent presentation decisions interfere with each other.
25
+
26
+ For example, a plugin rendering toolbar buttons and a tab bar can use separate
27
+ surfaces for a background page and each modal:
28
+
29
+ | Surface | Native content | When a Web image is useful |
30
+ | --------------- | --------------------------- | -------------------------------------------------------------------------- |
31
+ | Background page | Toolbar buttons and tab bar | A Web modal or popover must cover the page's controls. |
32
+ | Modal | Close and confirm buttons | The modal moves during a drag, or another Web overlay covers its controls. |
33
+
34
+ When a modal opens, the background surface can switch to its prepared images while
35
+ the modal's own controls remain Native when appropriate. Closing the modal requests
36
+ restoration of the background controls at their current positions; their images are
37
+ removed after the accepted Native update. Separate surfaces prevent a departing
38
+ modal's images or late replies from taking ownership of the newly exposed page.
39
+
40
+ ## Connect the existing renderer
41
+
42
+ Connect each phase to the corresponding event in your renderer. These snippets
43
+ show separate lifecycle points, not calls to execute together during initialization.
44
+ The renderer supplies `transport`, source IDs and frames, accepted revisions and
45
+ its existing update scheduler. In this example, `ownerOf(element)` is the
46
+ renderer's existing lookup of the page or modal that owns a control.
47
+
48
+ ### Create a surface when its owner becomes available
49
+
50
+ Map native control sources to existing fallback containers. The collection reuses
51
+ the surface for repeated updates to the same owner. The owner passed to `ensure`
52
+ is a lifecycle key, not a source filter: `surfaces.consider` broadcasts the batch
53
+ to every surface. Each surface uses `eligible` to select only its own controls;
54
+ two surfaces must not manage the same fallback container:
55
+
56
+ ```ts
57
+ import { createWebProjectionSurface, createWebProjectionSurfaces, type WebProjectionSurface } from '@webview-native-ui/engine';
58
+
59
+ const surfaces = createWebProjectionSurfaces<HTMLElement, WebProjectionSurface>();
60
+ const projection = await surfaces.ensure(page, () =>
61
+ createWebProjectionSurface(transport, {
62
+ namespace: 'my-renderer',
63
+ target: (source) => source.element,
64
+ eligible: (source) => ownerOf(source.element) === page,
65
+ wake: scheduleUpdate,
66
+ blocked: () => transitionIsActive,
67
+ }),
68
+ );
69
+ ```
70
+
71
+ ### Prepare during normal updates, hand off at presentation boundaries
72
+
73
+ An accepted native update provides the appearance to prepare. Beginning a handoff
74
+ uses that prepared image; ending one requests restoration rather than immediately
75
+ removing it. While a surface owns the image, exclude its `owners` from ordinary
76
+ native rendering so live controls do not appear above the covering Web layer.
77
+ Use `visibleOwners` for return-image ownership while restoring. Retire the image
78
+ only after the normal native update is acknowledged.
79
+
80
+ ```ts
81
+ // After accepting an ordinary native update:
82
+ surfaces.consider(acceptedRevision, sources);
83
+
84
+ // When the framework reports coverage or motion for this owner:
85
+ projection?.begin();
86
+
87
+ // When that coverage or motion ends:
88
+ projection?.end();
89
+ // Send the renderer's normal native update, then on its acknowledgement:
90
+ surfaces.updated(token, restoredIDs, acceptedRevision);
91
+ ```
92
+
93
+ ### Retire a page or the renderer
94
+
95
+ Deleting an owner prevents late creation or restoration from reviving its controls.
96
+ Destroy the collection when the renderer itself is disposed.
97
+
98
+ ```ts
99
+ await surfaces.delete(page); // Page retirement.
100
+ // At renderer teardown:
101
+ await surfaces.destroy();
102
+ ```
103
+
104
+ ## Placement and shared monitoring
105
+
106
+ The renderer supplies Native IDs and frames, source ownership, placement policy and
107
+ accepted update acknowledgements. Framework lifecycle events remain outside the
108
+ Engine; it does not infer modal semantics from arbitrary DOM mutations.
109
+
110
+ For renderer-owned fallback proxies, `canRelocate(target)` explicitly permits
111
+ `begin(layer)` to place those hosts in a supplied overlay layer. The Engine restores
112
+ their parent and sibling order after restoration, failure or disposal. Application
113
+ DOM is not relocated by default, and an application reparenting a proxy takes
114
+ precedence over restoring its old location.
115
+
116
+ A collection shares appearance, resize, orientation and document visibility
117
+ listeners across its surfaces, and ignores Engine-owned image events. Renderers can
118
+ exclude their own proxy events with `ignoreTarget`. `followMotion(moving, target)`
119
+ uses the renderer's existing moving-element map to rebase images only while their
120
+ own hosts animate; ancestor transforms already move the images without polling.
121
+ All listeners and scheduled frames are removed at disposal.
122
+
123
+ Repeated `ensure` calls share pending creation or the existing projection. Deletion,
124
+ DOM disconnection or renderer disposal prevents a late result from becoming active;
125
+ the Engine destroys it instead. A failed creation may be retried. The renderer
126
+ signals page retirement through its existing lifecycle and can inspect `keys()`
127
+ and `values()` during its normal update pass. The collection does not install a
128
+ second DOM mutation observer or a continuous polling loop.
129
+
130
+ ## Verify the handoff in the consuming renderer
131
+
132
+ Exercise an opening overlay, dismissal, a moving modal and owner removal during
133
+ restoration. The background and foreground must retain their own controls; a late
134
+ acknowledgement must not restore a departed owner. Change position without changing
135
+ appearance to check image reuse, then change content or dimensions to check
136
+ invalidation. Also exercise missing images: the ordinary fallback must remain
137
+ available without delaying overlay startup.
138
+
139
+ The Engine coordinates ownership, but only the consuming application can verify
140
+ its layer choices and native placement. Implement the native side using the
141
+ [native handoff contract](native-handoff.md), using `NativeUICoveredSnapshots.handle` on iOS and `NativeUISnapshots.handle` on
142
+ Android. Supply eligible native bounds and complete views allowed to hide; Engine
143
+ validates requests and converts destination coordinates. Your renderer still
144
+ applies normal native layout and reports accepted update acknowledgements.
145
+
146
+ For different ownership needs, see [low-level Web projection](web-projection.md).
147
+ Update scheduling is independent: use the optional [update queue](update-queue.md)
148
+ only when it fits the renderer's reconciliation policy.
@@ -0,0 +1,58 @@
1
+ # Serialize state updates and recover a lost acknowledgement
2
+
3
+ Use `createNativeUpdateQueue` when a renderer reads DOM state asynchronously and
4
+ sends native updates. Event bursts should produce one current-state read, and a
5
+ second read must not overlap the previous pass. This helper is optional: it does
6
+ not change native registrations or other projection APIs. Keep an existing scheduler
7
+ if the renderer needs a different cadence or recovery policy. The queue batches requests on
8
+ `requestAnimationFrame`; requests arriving during a pass trigger one later pass.
9
+
10
+ For example, a resize, a selection update and a DOM mutation arrive in one frame
11
+ while the previous native update is still awaiting acknowledgement. Queue one
12
+ fresh-state pass instead of starting overlapping reads. If the bridge loses an
13
+ acknowledgement, reconcile current state before restoring updates; never use this
14
+ queue to retry a purchase, click or advertisement load.
15
+
16
+ ```ts
17
+ import { createNativeUpdateQueue } from '@webview-native-ui/engine';
18
+
19
+ const updates = createNativeUpdateQueue(window, {
20
+ async update(send) {
21
+ const state = await renderer.readCurrentState();
22
+ const reply = await send(
23
+ () => renderer.sendState(state),
24
+ (reply) => {
25
+ if (reply.revision !== state.revision) throw new Error('Revision mismatch');
26
+ },
27
+ );
28
+ if (reply) renderer.accept(state, reply);
29
+ },
30
+ retry: () => renderer.invalidateLastAcceptedState(),
31
+ failed: (error) => renderer.stopAndRestoreFallback(error),
32
+ reconnect: true,
33
+ });
34
+
35
+ // From existing layout/content notifications:
36
+ updates.request();
37
+ // After the existing connection check has reconciled native session/revision:
38
+ updates.reconnected();
39
+ // At renderer teardown:
40
+ updates.destroy();
41
+ ```
42
+
43
+ `renderer` represents the integration's existing code. `send` is only for
44
+ replaceable state, called sequentially inside an update pass, never clicks,
45
+ selection actions, purchases or SDK requests. A failed send skips the remaining sends in that pass and requests one fresh
46
+ pass, not a replay of its payload. If that also fails, `reconnect: true` waits for
47
+ an external handshake; without it the queue reports terminal failure. An invalid
48
+ acknowledgement waits for reconciliation immediately. After reconciliation, another
49
+ failed retry or invalid acknowledgement is terminal; a fully successful update pass resets the
50
+ recovery budget. The queue does not install a connection poller or subscriptions.
51
+
52
+ Keep operation timeouts in the transport. A timeout does not cancel an underlying
53
+ native call: the native renderer must reject stale revisions, and the connection
54
+ check must reconcile current state before declaring recovery. Disposal suppresses
55
+ late send results and future passes; it cannot undo a native operation already sent.
56
+ Use `dirty` to reject obsolete DOM measurements, `running` to defer connection
57
+ reconciliation during a pass, and `waiting` to detect a pending recovery. The
58
+ renderer still owns revision assignment, accepted geometry and fallback policy.
@@ -0,0 +1,88 @@
1
+ # Low-level Web projection
2
+
3
+ Use the primitives below only if the surface helpers do not fit your renderer's
4
+ existing ownership model. `createWebProjectionSurface` already manages scopes and
5
+ batch handoffs; manually creating another manager for the same fallback would
6
+ introduce competing ownership.
7
+
8
+ For example, a renderer already owns one fallback container per native panel and
9
+ accepts native updates through its own revision protocol. A projection scope lets
10
+ it retain those ownership decisions while delegating image preparation and return
11
+ coordination. Prefer the surface helpers if page or modal grouping already fits;
12
+ a second manager on the same container would compete for its pixels.
13
+
14
+ A scope binds one existing Web fallback element for its lifetime. Obtain that element from your plugin's DOM; the engine does not create a replacement host or choose a page, stacking order or z-index. A different fallback element requires a new scope. Its Document is derived from `element.ownerDocument`.
15
+
16
+ ```ts
17
+ import { createWebProjectionScope, createWebProjection } from '@webview-native-ui/engine';
18
+
19
+ const fallback = container.querySelector<HTMLElement>('[data-web-fallback]');
20
+ if (!fallback) throw new Error('Web fallback is missing');
21
+ const scope = createWebProjectionScope(fallback, 'my-plugin');
22
+ const projection = await createWebProjection(
23
+ scope,
24
+ {
25
+ send: (request) => plugin.snapshot(request),
26
+ listen: (listener) => plugin.addListener('snapshotInteraction', listener),
27
+ },
28
+ {
29
+ wake: () => schedulePluginUpdate(),
30
+ blocked: () => overlayIsActive(),
31
+ revision: () => lastSentRevision,
32
+ },
33
+ );
34
+ ```
35
+
36
+ The container, bridge and update callbacks above belong to the consuming plugin. No `document`, `target`, `layer`, CSS selector or layer resolver is passed to the projection manager.
37
+
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
+ 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.
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
+ 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
+
45
+ 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
+
47
+ 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.
48
+
49
+ The image is absolutely positioned within the fallback, which retains its layout box. Static hosts become positioned while the image is visible. Host and ancestor clipping, transforms and filters still apply. Open ShadowRoot insertion bypasses internal slot wrappers, not the host's own clipping. Closed shadow roots cannot be inspected; provide a fallback container that can render the image.
50
+
51
+ Engine-owned nodes carry `data-native-ui-projection-node` for their entire pooled lifetime. Renderers that inspect fallback children must exclude these nodes from application content discovery. A renderer that uses a MutationObserver to invalidate captures must also ignore insertion/removal and attribute changes of these nodes, changes inside them, and Engine-owned `data-native-ui-snapshot-*` attributes. Continue observing authored content, dimensions and disabled-state changes; ignoring all mutations on a projection host would hide real updates. Otherwise image preparation can invalidate itself and cause repeated capture/release cycles. The engine removes only its own nodes on disposal. Child insertion can affect structural selectors within the fallback, but does not add items alongside it in flex, grid or virtual lists.
52
+
53
+ ### Notifications from the existing plugin
54
+
55
+ The low-level manager can subscribe to the existing plugin's events through `options.events`:
56
+
57
+ ```ts
58
+ events: {
59
+ addListener: (name, listener) => plugin.addListener(name, listener),
60
+ update: ['setColors', 'setTabs', 'tabSelected'],
61
+ projection: {
62
+ native: ['shown'],
63
+ web: ['transitionStarted'],
64
+ none: ['hidden'],
65
+ },
66
+ }
67
+ ```
68
+
69
+ These are **notifications**, not intercepted method names. The plugin emits update events after its native changes succeed. A Web projection event must arrive before movement starts; it uses the previously prepared image. A native event requests restoration, but the image remains until the normal renderer acknowledgement confirms that Native is ready. A hidden event follows the plugin's native hide operation and retires the image; it does not invoke `hide()` or change application-owned fallback DOM. Appearance updates preserve the requested destination, including `none`.
70
+
71
+ For plugins with several surfaces, provide `accepts(event)` to select this scope's stable surface identity. Events shared by `update` and a destination are subscribed once and processed together. Conflicting destinations for the same event are rejected before subscription. Destroying the manager removes all its subscriptions, including registrations that resolve late after initialization fails. No public plugin methods are wrapped or replayed.
72
+
73
+ The manager uses the host runtime's resource URL conversion internally (Capacitor or Cordova). Plugin authors do not supply a URL resolver.
74
+
75
+ This section describes the lower-level manager for renderers that already own batch reconciliation, rather than a single native-view registration. Prefer the native registration in the plugin guides for a new integration; it owns startup, measurements and draw acknowledgements.
76
+
77
+ ### Multiple fallbacks in one Native transaction
78
+
79
+ For example, a toolbar contains two button groups with separate fallback hosts,
80
+ but the renderer hides and restores them in one native transaction. Independent
81
+ managers could expose one group before the other. A batch uses one handoff token
82
+ for the groups so they can cross the rendering boundary together.
83
+
84
+ Use `createWebProjectionBatch(transport, options)` when several fallback elements must switch together. It uses the same image engine, one listener and one capture/cover/restore token. Pass `{ scope, element, control, ... }` for each entry to `batch.consider(revision, sources)`. Several native IDs may share one scope, such as a button group.
85
+
86
+ The batch derives its Document from the first nonempty `consider`; all subsequent scopes and sources must belong to that Document. Before this first update, `begin()` does nothing and `destroy()` is safe. A changed fallback requires a new scope. Scope identity participates in cache invalidation; an active presentation retains its captured scopes until restoration finishes.
87
+
88
+ `WebProjectionRequest` is an action-discriminated union. `prepare` requires `revision`, `viewportWidth` and `ids`; `cover` requires `token` and includes `viewportWidth` and destination `parts` for transient movement; `restore` requires `token`, `viewportWidth` and destination `parts`; `painted`, `restored` and `release` require `token`; `reset` takes no payload. Empty preparation results and `{ shown: false }` mean the plugin should use its normal Web fallback. Use the plugin's existing ordered bridge; the engine does not retry user actions.