view-anchor 0.1.2 → 0.2.0

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 (44) hide show
  1. package/README.md +118 -34
  2. package/README.zh-CN.md +128 -44
  3. package/dist/index.d.ts +4 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -14
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +37 -16
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +191 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +131 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +20 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +206 -174
  30. package/docs/bidirectional-design.md +64 -96
  31. package/docs/{anchor-3d.html → index.html} +215 -73
  32. package/docs/mechanism.mdx +55 -49
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +79 -0
  35. package/package.json +30 -4
  36. package/src/index.ts +6 -15
  37. package/src/measure-loop.ts +36 -41
  38. package/src/protocol-publisher.ts +236 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +193 -0
  41. package/src/react.ts +186 -141
  42. package/src/size-advertiser.ts +24 -31
  43. package/src/types.ts +34 -79
  44. package/src/view-anchor.ts +228 -212
package/src/react.ts CHANGED
@@ -1,171 +1,216 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
- import { createViewAnchor } from './view-anchor.js'
3
- import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
4
-
5
- /**
6
- * React adapter over the imperative `createViewAnchor` core.
7
- *
8
- * (React lint forces the `use` prefix on any hook returning a ref
9
- * callback; the library's identity is still the `ViewAnchor` core — this is
10
- * just the React binding.)
11
- */
2
+ import {
3
+ createPlacementAnchor,
4
+ createViewAnchor,
5
+ type PlacementAnchorHandle,
6
+ type PlacementAnchorOptions,
7
+ } from './view-anchor.js'
8
+ import type {
9
+ Bounds,
10
+ ViewAnchorHandle,
11
+ ViewAnchorOptions,
12
+ } from './types.js'
13
+
12
14
  export interface UseViewAnchorOptions extends ViewAnchorOptions {
13
15
  /**
14
- * Non-DOM dependencies that move the target's rect and must force a
15
- * re-publish (layout signature, project path, a tab toggle's
16
- * `display:none`, …). A `ResizeObserver` covers pure geometry; `deps`
17
- * covers state it cannot see. Keep the array length stable across
18
- * renders (React effect-deps rule).
16
+ * Values that re-apply the anchor when changed. Keep this array's length
17
+ * stable across renders.
18
+ */
19
+ deps?: ReadonlyArray<unknown>
20
+ }
21
+
22
+ /** Compatible with React 18's null callback and React 19's ref cleanup. */
23
+ export type ViewAnchorRef = (el: HTMLElement | null) => void | (() => void)
24
+
25
+ export interface UsePlacementAnchorOptions extends PlacementAnchorOptions {
26
+ /**
27
+ * Values that re-apply the anchor when changed. Keep this array's length
28
+ * stable across renders.
19
29
  */
20
30
  deps?: ReadonlyArray<unknown>
21
31
  }
22
32
 
23
- export type ViewAnchorRef = (el: HTMLElement | null) => void
24
-
25
- /**
26
- * Bind a native view's bounds to whichever DOM element the returned ref
27
- * callback is attached to. On attach → `createViewAnchor(el, opts)`; on
28
- * detach (`null`) publish ZERO then `dispose()`; on `opts`/`deps` change →
29
- * `update`; on unmount → publish ZERO then `dispose`.
30
- *
31
- * Why ZERO on disappearance: the anchor's follower is a *main-process*
32
- * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
33
- * `dispose()` only stops observing — it deliberately never publishes again
34
- * (its Contract 6/7). But the host only collapses the native view when it
35
- * receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
36
- * (not `display:none`) when hidden, so the ref goes to `null` and the native
37
- * view would otherwise stay frozen at its last bounds, floating on
38
- * top and occluding content. So the adapter (not core) must emit one ZERO via
39
- * the already-tested `update({ present:false })` path before disposing.
40
- */
41
- export function useViewAnchor(opts: UseViewAnchorOptions): ViewAnchorRef {
42
- const handleRef = useRef<ViewAnchorHandle | null>(null)
43
- const elRef = useRef<HTMLElement | null>(null)
44
- // Latest opts, read by the stable ref callback when it creates the anchor.
45
- // Synced render-synchronously (NOT in an effect): the ref callback reads
46
- // `optsRef.current` during *commit* (when the element attaches), which runs
47
- // before passive effects. An effect-synced ref would be one render stale at
48
- // that point, so a hidden→shown remount (`present` flips false→true together
49
- // with the element re-mounting, exactly what the debug cell does) would
50
- // create the anchor with the old `present:false` and emit a spurious ZERO
51
- // before the real rect. A render write keeps it current at commit, and is
52
- // idempotent under StrictMode's double render.
53
- const optsRef = useRef(opts)
54
- // eslint-disable-next-line react-hooks/refs -- see above: must be current at commit, before effects run
55
- optsRef.current = opts
56
-
57
- // Baseline for the re-apply effect's change detection. Declared here (before
58
- // the ref callback) so the callback can re-seed it on (re)create.
59
- const appliedRef = useRef<ReadonlyArray<unknown>>([
60
- opts.present,
61
- opts.publish,
62
- ...(opts.deps ?? []),
63
- ])
64
-
65
- // Collapse the native view (publish ZERO) and tear the anchor down. Reuse
66
- // the existing, tested `update({ present:false })` path: it synchronously
67
- // publishes `{0,0,0,0}` and stops observing (core Contract 5), then
68
- // `dispose()` makes the anchor inert. Idempotent via the `handleRef.current`
69
- // null-check so the two callers below can never double-emit ZERO.
70
- const collapseAndDispose = useRef((): void => {
33
+ /** Callback ref for the explicit-visibility Placement API. */
34
+ export type PlacementAnchorRef = ViewAnchorRef
35
+
36
+ type AnchorHandle = { dispose(): void }
37
+
38
+ interface LifecycleAdapter<Options, Handle extends AnchorHandle> {
39
+ create(target: HTMLElement, options: Options): Handle
40
+ update(handle: Handle, options: Options): void
41
+ collapse(handle: Handle, options: Options): void
42
+ isCollapsed(options: Options): boolean
43
+ }
44
+
45
+ // Callback refs own the imperative anchor because React invokes them during commit.
46
+ // React 19 may call the cleanup returned from a ref and immediately reattach the
47
+ // same element in development mode. Collapse is deferred by one microtask so that
48
+ // immediate reattachment cancels the collapse.
49
+ function useAnchorRef<Options, Handle extends AnchorHandle>(
50
+ options: Options,
51
+ applied: ReadonlyArray<unknown>,
52
+ adapter: LifecycleAdapter<Options, Handle>,
53
+ ): ViewAnchorRef {
54
+ const handleRef = useRef<Handle | null>(null)
55
+ const elementRef = useRef<HTMLElement | null>(null)
56
+ const optionsRef = useRef(options)
57
+ // eslint-disable-next-line react-hooks/refs
58
+ optionsRef.current = options
59
+ const adapterRef = useRef(adapter)
60
+ // eslint-disable-next-line react-hooks/refs
61
+ adapterRef.current = adapter
62
+ const appliedRef = useRef(applied)
63
+ const currentAppliedRef = useRef(applied)
64
+ // eslint-disable-next-line react-hooks/refs
65
+ currentAppliedRef.current = applied
66
+ // Options handed to the adapter on the last create/update call.
67
+ // Tracks applied state across renders where the deps array reference changes.
68
+ const lastAppliedOptionsRef = useRef(options)
69
+ const detachTokenRef = useRef(0)
70
+
71
+ const cancelPendingDetach = (): void => {
72
+ detachTokenRef.current++
73
+ }
74
+
75
+ const collapseAndDispose = (): void => {
71
76
  const handle = handleRef.current
72
77
  if (!handle) return
73
- handle.update({ present: false, publish: optsRef.current.publish })
74
- handle.dispose()
75
- handleRef.current = null
76
- })
77
-
78
- const ref = useCallback<ViewAnchorRef>((el) => {
79
- if (el === elRef.current) return
80
- elRef.current = el
78
+ const adapter = adapterRef.current
79
+ const alreadyCollapsed = adapter.isCollapsed(lastAppliedOptionsRef.current)
80
+ try {
81
+ if (!alreadyCollapsed) adapter.collapse(handle, optionsRef.current)
82
+ } finally {
83
+ handleRef.current = null
84
+ handle.dispose()
85
+ }
86
+ }
87
+
88
+ const deferDetach = (element: HTMLElement): void => {
89
+ const token = ++detachTokenRef.current
90
+ queueMicrotask(() => {
91
+ if (detachTokenRef.current !== token || elementRef.current !== element) return
92
+ elementRef.current = null
93
+ collapseAndDispose()
94
+ })
95
+ }
96
+
97
+ const ref = useCallback<ViewAnchorRef>((element) => {
98
+ if (element === elementRef.current) {
99
+ cancelPendingDetach()
100
+ return element ? () => deferDetach(element) : undefined
101
+ }
102
+
103
+ cancelPendingDetach()
104
+ const previous = elementRef.current
81
105
  if (handleRef.current) {
82
- if (el) {
83
- // Swapping to *another* live element: dispose the old anchor without a
84
- // ZERO. The new element publishes its real rect immediately below, so
85
- // a transient ZERO between the two would only cause a needless
86
- // detach/re-attach flicker of the native view.
106
+ if (element) {
87
107
  handleRef.current.dispose()
88
108
  handleRef.current = null
89
- } else {
90
- // Element detached (ref → null): the anchor point is gone, so collapse
91
- // the native view (one ZERO) before disposing.
92
- collapseAndDispose.current()
109
+ } else if (previous) {
110
+ deferDetach(previous)
111
+ return undefined
93
112
  }
94
113
  }
95
- if (el) {
96
- handleRef.current = createViewAnchor(el, {
97
- present: optsRef.current.present,
98
- publish: optsRef.current.publish,
99
- })
100
- // The anchor was just created at the current (present, publish, deps), so
101
- // seed the re-apply baseline to match. Otherwise the post-commit re-apply
102
- // effect would see this fresh state as a change and publish a second time
103
- // — on a remount with a changed `present` that is a double-emit.
104
- appliedRef.current = [
105
- optsRef.current.present,
106
- optsRef.current.publish,
107
- ...(optsRef.current.deps ?? []),
108
- ]
114
+
115
+ if (element) {
116
+ elementRef.current = element
117
+ handleRef.current = adapterRef.current.create(element, optionsRef.current)
118
+ appliedRef.current = currentAppliedRef.current
119
+ lastAppliedOptionsRef.current = optionsRef.current
120
+ return () => deferDetach(element)
109
121
  }
122
+ return undefined
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
110
124
  }, [])
111
125
 
112
- // Re-apply on opts/deps change. We must `update` whenever the
113
- // (present, publish, …deps) tuple actually changes, but NOT on the mount run
114
- // (the ref callback already created the anchor and published once) and NOT on
115
- // a StrictMode replay (dev double-fires this effect's setup with the *same*
116
- // tuple — a blind `update` then re-publishes the mount rect a second time).
117
- // So instead of guessing "is this the first run?", compare against the
118
- // last-applied tuple and apply only on a genuine change. The tuple is seeded
119
- // with the mount opts, so the mount run and its StrictMode replay both see
120
- // "unchanged" and skip — idempotent by construction. `deps` keeps a stable
121
- // length across renders (documented above), so positional compare is sound.
122
126
  useEffect(() => {
123
- const next: ReadonlyArray<unknown> = [
124
- opts.present,
125
- opts.publish,
126
- ...(opts.deps ?? []),
127
- ]
128
- const prev = appliedRef.current
127
+ const previous = appliedRef.current
129
128
  const changed =
130
- next.length !== prev.length || next.some((v, i) => !Object.is(v, prev[i]))
129
+ applied.length !== previous.length ||
130
+ applied.some((value, index) => !Object.is(value, previous[index]))
131
131
  if (!changed) return
132
- appliedRef.current = next
133
- handleRef.current?.update({ present: opts.present, publish: opts.publish })
132
+ appliedRef.current = applied
133
+ const handle = handleRef.current
134
+ if (handle) {
135
+ adapterRef.current.update(handle, optionsRef.current)
136
+ lastAppliedOptionsRef.current = optionsRef.current
137
+ }
134
138
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
- }, [opts.present, opts.publish, ...(opts.deps ?? [])])
136
-
137
- // Collapse the native view + dispose on teardown.
138
- //
139
- // StrictMode-safe lifecycle: this effect's setup/cleanup is double-fired in
140
- // dev (setup → cleanup → setup). The anchor itself is created/owned by the
141
- // ref callback, which in React 18 fires exactly once on mount and once with
142
- // `null` on a real detach — it is NOT replayed by StrictMode. So this effect
143
- // must not destroy the ref-owned anchor on a *throwaway* unmount, or the
144
- // re-setup would have nothing to restore.
145
- //
146
- // Discriminator: on a real teardown React detaches the element first
147
- // (`ref(null)` → `elRef.current === null`, and that path already emitted the
148
- // single ZERO + disposed); on a StrictMode throwaway unmount the element is
149
- // still attached (`elRef.current !== null`, ref never fired `null`). So we
150
- // only collapse here when the element is genuinely gone, and otherwise leave
151
- // the live anchor intact for the immediate re-setup.
152
- //
153
- // The setup re-establishes the anchor if a prior cleanup ever tore it down
154
- // while the element is still attached, keeping setup/cleanup symmetric.
139
+ }, applied)
140
+
155
141
  useEffect(() => {
156
- const collapse = collapseAndDispose.current
157
- if (elRef.current && !handleRef.current) {
158
- handleRef.current = createViewAnchor(elRef.current, {
159
- present: optsRef.current.present,
160
- publish: optsRef.current.publish,
161
- })
162
- }
142
+ cancelPendingDetach()
163
143
  return () => {
164
- if (elRef.current === null) collapse()
144
+ const element = elementRef.current
145
+ if (element) deferDetach(element)
165
146
  }
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
166
148
  }, [])
167
149
 
168
150
  return ref
169
151
  }
170
152
 
153
+ const viewAdapter: LifecycleAdapter<ViewAnchorOptions, ViewAnchorHandle> = {
154
+ create: createViewAnchor,
155
+ update(handle, options) {
156
+ handle.update(options)
157
+ },
158
+ collapse(handle, options) {
159
+ handle.update({ present: false, publish: options.publish })
160
+ },
161
+ isCollapsed(options) {
162
+ return !options.present
163
+ },
164
+ }
165
+
166
+ /** Bind zero-bounds visibility to a DOM element callback ref. */
167
+ export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
168
+ return useAnchorRef(
169
+ options,
170
+ [options.present, options.publish, ...(options.deps ?? [])],
171
+ viewAdapter,
172
+ )
173
+ }
174
+
175
+ const placementAdapter: LifecycleAdapter<
176
+ PlacementAnchorOptions,
177
+ PlacementAnchorHandle
178
+ > = {
179
+ create: createPlacementAnchor,
180
+ update(handle, options) {
181
+ // In React, an omitted option represents "off" for that render,
182
+ // rather than keeping the previous value.
183
+ handle.update({
184
+ ...options,
185
+ guardDisplayNone: options.guardDisplayNone ?? false,
186
+ followScroll: options.followScroll ?? false,
187
+ followGeometry: options.followGeometry ?? false,
188
+ })
189
+ },
190
+ collapse(handle, options) {
191
+ handle.update({ ...options, visible: false })
192
+ },
193
+ isCollapsed(options) {
194
+ return !options.visible
195
+ },
196
+ }
197
+
198
+ /** Bind the explicit Placement API to a DOM element callback ref. */
199
+ export function usePlacementAnchor(
200
+ options: UsePlacementAnchorOptions,
201
+ ): PlacementAnchorRef {
202
+ return useAnchorRef(
203
+ options,
204
+ [
205
+ options.visible,
206
+ options.publish,
207
+ options.guardDisplayNone,
208
+ options.followScroll,
209
+ options.followGeometry,
210
+ ...(options.deps ?? []),
211
+ ],
212
+ placementAdapter,
213
+ )
214
+ }
215
+
171
216
  export type { Bounds }
@@ -1,51 +1,46 @@
1
1
  import type {
2
2
  AdvertisedSize,
3
+ Publisher,
3
4
  SizeAdvertiserOptions,
4
5
  SizeAdvertiserHandle,
5
6
  } from './types.js'
6
7
  import { createMeasureLoop } from './measure-loop.js'
7
8
 
8
9
  /**
9
- * Reverse of `createViewAnchor`: runs in a downstream WebContentsView's own
10
- * renderer, measures the content's own size on ONE owned axis (from the
11
- * `ResizeObserver` border-box — no `getBoundingClientRect`, no forced reflow),
12
- * and advertises it via the injected `publish`. Shares the forward primitive's
13
- * measure/coalesce/dedupe/dispose engine (`createMeasureLoop`).
10
+ * Report content size for a single axis back to the host.
14
11
  *
15
- * The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
16
- * drop the frame.
12
+ * Runs in a downstream document, reads the content size from
13
+ * `ResizeObserverEntry.borderBoxSize` without triggering reflow, and
14
+ * publishes updates through an animation frame loop (`createMeasureLoop`).
17
15
  *
18
- * FOOTGUN `target` must be shrink-to-fit on the owned axis: its owned-axis
19
- * size must NOT be driven by the host-applied view size, or the cross-process
20
- * loop (advertise host resizes view remeasure) never converges (it
21
- * oscillates or stays "stable but wrong"). Measuring `<body>`/`<html>` is the
22
- * classic mistake their size *is* the view size. See
23
- * `docs/bidirectional-design.md`'s single-axis-ownership and trust-boundary
24
- * sections.
16
+ * Measurements are rounded to integer pixels and clamped to >= 0.
17
+ *
18
+ * Note: `target` should be a shrink-to-fit wrapper on the owned axis.
19
+ * If its size is driven by the host view itself (such as `<body>` or `<html>`),
20
+ * updates will not shrink back to content size.
25
21
  */
26
22
  export function createSizeAdvertiser(
27
23
  target: HTMLElement,
28
24
  opts: SizeAdvertiserOptions,
29
25
  ): SizeAdvertiserHandle {
30
- const axis = opts.axis // immutable for the advertiser's life
26
+ const axis = opts.axis
31
27
  let publish = opts.publish
32
28
  let observer: ResizeObserver | null = null
33
29
  let disposed = false
34
- // Latest border-box, stashed by the RO callback and read by `produce` in the
35
- // RAF body (keep the entry out of the shared, DOM-agnostic loop).
30
+ // Latest border-box recorded by the ResizeObserver callback.
36
31
  let latest: ResizeObserverSize | null = null
37
32
 
38
- const produce = (): AdvertisedSize | null => {
33
+ const produce = (): number | null => {
39
34
  if (!latest) return null
40
35
  const raw = axis === 'block' ? latest.blockSize : latest.inlineSize
41
36
  if (!Number.isFinite(raw)) return null
42
- return { axis, extent: Math.max(0, Math.round(raw)) }
37
+ return Math.max(0, Math.round(raw))
43
38
  }
44
39
 
45
- const loop = createMeasureLoop<AdvertisedSize>({
40
+ const loop = createMeasureLoop<number>({
46
41
  produce,
47
- same: (a, b) => a.extent === b.extent, // axis is constant
48
- sink: (size) => publish(size),
42
+ same: (a, b) => a === b,
43
+ sink: (extent) => publish({ axis, extent }),
49
44
  })
50
45
 
51
46
  const onResize: ResizeObserverCallback = (entries) => {
@@ -56,14 +51,13 @@ export function createSizeAdvertiser(
56
51
  loop.schedule()
57
52
  }
58
53
 
59
- // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
54
+ // Warn if measuring body or documentElement, whose size matches the view.
60
55
  const doc = target.ownerDocument
61
56
  if (target === doc.body || target === doc.documentElement) {
62
57
  console.warn(
63
58
  `[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
64
- `${axis} size is the host-given view size, not the content size the ` +
65
- `advertiser will never shrink to content. Measure a shrink-to-fit wrapper. ` +
66
- `See bidirectional-design.md's single-axis-ownership section.`,
59
+ `${axis} size is the view size, not content size. The advertiser will ` +
60
+ `never shrink to content; measure a shrink-to-fit wrapper instead.`,
67
61
  )
68
62
  }
69
63
 
@@ -72,14 +66,13 @@ export function createSizeAdvertiser(
72
66
  observer.observe(target)
73
67
 
74
68
  return {
75
- update(nextPublish: (size: AdvertisedSize) => void): void {
69
+ update(nextPublish: Publisher<AdvertisedSize>): void {
76
70
  if (disposed) return
77
71
  publish = nextPublish
78
- // Re-advertise the current size to the new sink immediately (mirrors the
79
- // forward anchor's re-publish on update) so the new channel is not left
80
- // sizeless until the next ResizeObserver tick.
72
+ // Re-publish the current size to the new sink immediately so it is not
73
+ // empty until the next ResizeObserver tick.
81
74
  const cur = produce()
82
- if (cur) loop.emitNow(cur)
75
+ if (cur !== null) loop.emitNow(cur)
83
76
  },
84
77
  dispose(): void {
85
78
  if (disposed) return
package/src/types.ts CHANGED
@@ -1,23 +1,8 @@
1
1
  /**
2
- * view-anchor sync a main-process native view's bounds to a DOM element.
3
- *
4
- * Self-contained, engine-agnostic primitive. It knows nothing about
5
- * Electron (the `publish` callback owns the IPC → `setBounds`), nothing
6
- * about React (the core is imperative; see `react.ts` for the adapter),
7
- * and nothing about the host layout engine (the `target` element may come
8
- * from our own `compile`/`FrameTree`, or a dockview panel's
9
- * `content.element`; the mechanism is identical).
10
- *
11
- * It is the modern, alive replacement for the archived
12
- * `react-electron-browser-view`: the cross-process bridge that DOM layout
13
- * libraries (dockview included) deliberately do not provide. dockview's
14
- * internal `OverlayRenderContainer` does the same getBoundingClientRect →
15
- * RAF → reposition dance, but its follower is a DOM node; ours is a native
16
- * `WebContentsView` positioned via the injected `publish`.
2
+ * Core geometry and transport types for view-anchor.
17
3
  */
18
4
 
19
- /** A screen-space rectangle, in CSS pixels. Structurally compatible with
20
- * the host's `ViewBounds` so a publisher typed against either works. */
5
+ /** A screen-space rectangle in CSS pixels. */
21
6
  export interface Bounds {
22
7
  x: number
23
8
  y: number
@@ -25,16 +10,20 @@ export interface Bounds {
25
10
  height: number
26
11
  }
27
12
 
13
+ /** Returning false declines a value; true and void accept it. */
14
+ export type PublishResult = void | boolean
15
+
28
16
  /**
29
- * Explicit visibility + geometry for a native view, replacing the legacy
30
- * magic-`{0,0,0,0}` "hidden" convention (`present:false ZERO bounds`).
17
+ * Synchronous publish callback. A batching transport returns true once
18
+ * the value is queued; subsequent delivery is handled by the transport.
19
+ */
20
+ export type Publisher<T> = (value: T) => PublishResult
21
+
22
+ /**
23
+ * Explicit visibility and bounds for an anchored view.
31
24
  *
32
- * Visibility is a DISCRIMINANT, never inferred from geometry. The whole
33
- * reason this type exists: a genuinely zero-SIZED but on-screen view
34
- * (`{ visible:true, bounds:{...,width:0,height:0} }`) is now distinct from a
35
- * detached/hidden one (`{ visible:false }`, which carries no `bounds` at
36
- * all). Under the old ZERO convention both collapsed to the same value and
37
- * were indistinguishable.
25
+ * Distinguishes an intentionally visible but zero-sized element ({ visible: true, bounds: 0x0 })
26
+ * from a hidden or detached element ({ visible: false }).
38
27
  */
39
28
  export type Placement =
40
29
  | { visible: true; bounds: Bounds }
@@ -42,82 +31,48 @@ export type Placement =
42
31
 
43
32
  export interface ViewAnchorOptions {
44
33
  /**
45
- * Whether the native view should be attached. When `false`, the anchor
46
- * publishes zero bounds (`{0,0,0,0}`) the host treats `width === 0 ||
47
- * height === 0` as "detach the child view but keep its WebContents
48
- * alive" (detach-but-keep-alive). No DOM measurement is needed in this
49
- * state.
34
+ * Whether the native view should be attached. When false, publishes
35
+ * zero bounds ({ x: 0, y: 0, width: 0, height: 0 }) so the host can detach
36
+ * the view while keeping its instance alive.
50
37
  */
51
38
  present: boolean
52
- /** Receives the live rect, or `{0,0,0,0}` when detached. Owns IPC. */
53
- publish: (bounds: Bounds) => void
39
+ /** Receives the live rect, or zero bounds when detached. */
40
+ publish: Publisher<Bounds>
54
41
  }
55
42
 
56
43
  export interface ViewAnchorHandle {
57
- /**
58
- * Apply new options. Re-publishes immediately to reflect the new state
59
- * (present=true → measure + observe; present=false → zero bounds).
60
- */
44
+ /** Apply new options and re-publish immediately. */
61
45
  update(opts: ViewAnchorOptions): void
62
- /** Stop observing and remove listeners. After dispose the anchor never
63
- * publishes again (every emit reads `disposed` synchronously, so there is
64
- * no queued frame that could fire late). */
46
+ /** Stop observing and clean up listeners. After disposal no further values are published. */
65
47
  dispose(): void
66
48
  }
67
49
 
68
- // ── Reverse direction: size advertiser ───────────────────────────────
50
+ // --- Reverse direction: size advertiser ---
69
51
  //
70
- // The mirror of the forward anchor. Runs in a DOWNSTREAM WebContentsView's own
71
- // renderer: it measures the content's own size and advertises it to the host,
72
- // which sizes the placeholder accordingly (and the forward anchor then keeps the
73
- // view positioned). One advertiser owns exactly ONE axis — the other axis is a
74
- // host-driven, read-only input — so the cross-process loop stays a one-way DAG.
52
+ // Runs in a downstream document to report content size back to the host,
53
+ // allowing the host's DOM placeholder to match the content.
75
54
 
76
- /** Which axis this advertiser owns. `block` = height, `inline` = width
77
- * (logical-property naming, axis-agnostic to writing mode). */
55
+ /** Which axis this advertiser reports: 'block' (height) or 'inline' (width). */
78
56
  export type AdvertisedAxis = 'block' | 'inline'
79
57
 
80
- /**
81
- * One frame of advertised size. A pure scalar plus the owning axis — there is
82
- * deliberately no field for the *other* axis, so "advertise two axes" is not
83
- * expressible (single-axis ownership is enforced in the type, not at runtime).
84
- */
58
+ /** One frame of advertised size on the owned axis. */
85
59
  export interface AdvertisedSize {
86
- /** Mirrors the factory's `axis`; constant across frames. Lets the host
87
- * whitelist-check the axis it is willing to accept. */
60
+ /** The axis this advertiser reports ('block' or 'inline'). */
88
61
  readonly axis: AdvertisedAxis
89
- /** The owned axis's content extent, in CSS px — already rounded and clamped
90
- * to `>= 0`. */
62
+ /** The content extent in CSS pixels, rounded and non-negative. */
91
63
  readonly extent: number
92
64
  }
93
65
 
94
66
  export interface SizeAdvertiserOptions {
95
- /** The single axis this advertiser owns. Fixed for the advertiser's life. */
67
+ /** The single axis this advertiser owns. Fixed for the advertiser's lifetime. */
96
68
  axis: AdvertisedAxis
97
- /** Receives each advertised size. Owns the IPC/postMessage → host. Mirrors
98
- * the forward `publish` (same role: the injected, transport-owning sink). */
99
- publish: (size: AdvertisedSize) => void
69
+ /** Receives each advertised size. */
70
+ publish: Publisher<AdvertisedSize>
100
71
  }
101
72
 
102
73
  export interface SizeAdvertiserHandle {
103
- /**
104
- * Swap the `publish` sink (e.g. a new IPC channel) and immediately
105
- * re-advertise the current size to it (mirrors the forward anchor's
106
- * re-publish on update), so the new channel is not left sizeless until the
107
- * next `ResizeObserver` tick.
108
- *
109
- * Takes only the new sink — `axis` is immutable by construction, so it is
110
- * deliberately not expressible here (you cannot attempt to change it). To
111
- * advertise a different axis, dispose and create a new advertiser.
112
- */
113
- update(publish: (size: AdvertisedSize) => void): void
114
- /**
115
- * Stop observing, cancel any pending RAF. After dispose nothing is
116
- * advertised again. (There is no ZERO/terminal value — collapsing is the
117
- * host's policy, unlike the forward anchor's `present:false`.)
118
- *
119
- * The first advertised value is asynchronous: it awaits the observer's first
120
- * frame, and a `display:none` target advertises nothing until shown.
121
- */
74
+ /** Swap the publish callback and re-advertise the current size immediately. */
75
+ update(publish: Publisher<AdvertisedSize>): void
76
+ /** Stop observing and cancel any pending animation frame. */
122
77
  dispose(): void
123
78
  }