view-anchor 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/protocol.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 通信协议
2
2
 
3
- `view-anchor` 核心模块只负责测量并同步调用 `publish`。如果需要在进程或 iframe 边界上传输几何数据,可以使用可选的 `view-anchor/protocol` 模块。它提供带版本的消息结构、输入校验、消息防乱序和微任务批处理,不绑定具体的传输通道。
3
+ `view-anchor` 核心模块只负责测量并同步调用 `publish`。如果应用要把几何数据交给异步或不完全可信的通道,可以使用可选的 `view-anchor/protocol` 模块。它提供带版本的消息结构、输入校验、消息防乱序和微任务批处理,不绑定具体的传递方式。
4
4
 
5
5
  一条消息从锚点走到宿主要经过的环节:
6
6
 
@@ -16,12 +16,12 @@ sequenceDiagram
16
16
  A->>P: publish(placement)
17
17
  P->>B: 带 anchorId、generation、seq 的消息
18
18
  Note over B: 同一微任务内合并,<br/>每个 anchorId 的 placement 和 size<br/>各自只留最新一条
19
- B->>D: IPC / postMessage
19
+ B->>D: 应用传递原始数据
20
20
  D->>D: 校验版本、类型、整数范围、批次上限
21
21
  alt 校验不通过
22
22
  D-->>H: 返回 ok: false,不抛异常
23
23
  else 校验通过
24
- Note over D,G: 宿主先验证发送方身份<br/>senderFrame / origin / token
24
+ Note over D,G: 接收方先验证来源身份
25
25
  D->>G: 逐条交给 guard
26
26
  alt generation 更旧,或同类型 seq 不大于已见最大值
27
27
  G-->>H: 丢弃
@@ -42,7 +42,7 @@ sequenceDiagram
42
42
  - `seq`:同一 publisher 内单调递增的序列号。
43
43
  - `placement` 或 `size`:具体的位置或尺寸数据。
44
44
 
45
- `generation` 作用于同一个 `anchorId`:一旦接收到新一代的消息,所有该锚点的旧代消息都会被直接丢弃。`generation` 与 `seq` 只负责保证消息顺序,不作为鉴权凭据。接收端依然应当在分发前验证发送方身份(如校验 `senderFrame`、域名或访问令牌)。
45
+ `generation` 作用于同一个 `anchorId`:一旦接收到新一代的消息,所有该锚点的旧代消息都会被直接丢弃。`generation` 与 `seq` 只负责保证消息顺序,不作为鉴权凭据。接收端仍应在分发前验证来源身份。
46
46
 
47
47
  ## 发送端
48
48
 
@@ -53,7 +53,7 @@ import {
53
53
  } from 'view-anchor/protocol'
54
54
 
55
55
  const batcher = createGeometryBatcher(
56
- (batch) => ipc.send('geometry', batch),
56
+ sendGeometryBatch,
57
57
  { onError: (err) => console.error(err) },
58
58
  )
59
59
 
@@ -88,7 +88,7 @@ if (result.ok) {
88
88
 
89
89
  for (const message of messages) {
90
90
  if (
91
- isAuthorized(event.senderFrame, message.anchorId) &&
91
+ isAuthorized(message.anchorId) &&
92
92
  guard.accept(message)
93
93
  ) {
94
94
  applyGeometry(message)
@@ -105,4 +105,4 @@ if (result.ok) {
105
105
  - 返回 `true` 或 `void`:表示数据已成功接收或已加入发送队列。
106
106
  - 返回 `false`:表示当前未接收。核心会在下一次测量触发时重新尝试该值。
107
107
 
108
- 如果底层传输是异步的(例如异步 IPC 或网络请求),应当先在同步回调中将消息放入本地发送队列并返回 `true`,后续的重试由发送队列自行管理。
108
+ 如果底层传递是异步的,应当先在同步回调中将消息放入本地队列并返回 `true`,后续的重试由发送队列自行管理。
package/package.json CHANGED
@@ -1,16 +1,8 @@
1
1
  {
2
2
  "name": "view-anchor",
3
- "version": "0.2.1",
4
- "description": "High-performance geometry bridge that keeps anything outside the DOM (an Electron WebContentsView, a native webview, a cross-origin iframe) aligned to a DOM element: synchronous, deduplicated updates, optional versioned message batching, React adapter, framework-free core.",
3
+ "version": "0.2.2",
4
+ "description": "High-performance geometry bridge that measures a DOM element and synchronously gives application code its current bounds: deduplicated updates, optional versioned message batching, React adapter, framework-free core.",
5
5
  "keywords": [
6
- "electron",
7
- "webcontentsview",
8
- "browserview",
9
- "webview",
10
- "native view",
11
- "iframe",
12
- "postmessage",
13
- "ipc",
14
6
  "dom",
15
7
  "geometry",
16
8
  "bounds",
@@ -73,6 +65,7 @@
73
65
  },
74
66
  "devDependencies": {
75
67
  "@testing-library/react": "^16.3.2",
68
+ "@changesets/cli": "^3.0.2",
76
69
  "@types/node": "^26.5.1",
77
70
  "@types/react": "^18.3.12",
78
71
  "@vitejs/plugin-react": "^6.0.1",
@@ -91,16 +84,17 @@
91
84
  },
92
85
  "scripts": {
93
86
  "build": "tsc -p tsconfig.build.json && pnpm run build:docs",
94
- "build:docs": "node scripts/build-docs.mjs",
95
- "check-types": "tsc --noEmit",
96
- "lint": "oxlint . --deny-warnings",
87
+ "build:docs": "node scripts/build-docs.js",
88
+ "check-types": "tsc --noEmit -p tsconfig.test.json",
89
+ "lint": "oxlint . --deny-warnings --report-unused-disable-directives",
97
90
  "format": "oxfmt --write .",
98
91
  "format:check": "oxfmt --check .",
99
92
  "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
100
93
  "test:dev": "vitest",
101
94
  "test:coverage": "vitest run --coverage",
102
- "benchmark": "node --expose-gc scripts/performance-report.mjs",
103
- "benchmark:v8": "node --trace-opt --trace-deopt --trace-turbo-inlining --expose-gc scripts/performance-report.mjs",
104
- "check-package": "node scripts/check-package.mjs"
95
+ "benchmark": "node --expose-gc scripts/performance-report.js",
96
+ "benchmark:v8": "node --trace-opt --trace-deopt --trace-turbo-inlining --expose-gc scripts/performance-report.js",
97
+ "check-package": "node scripts/check-package.js",
98
+ "release": "pnpm run build && pnpm run test && pnpm run check-package && changeset publish"
105
99
  }
106
100
  }
package/src/abort.ts ADDED
@@ -0,0 +1,9 @@
1
+ const NOOP = (): void => {}
2
+
3
+ /** Attach a one-shot abort listener and return a function that detaches it. */
4
+ export function watchAbort(signal: AbortSignal | undefined, dispose: () => void): () => void {
5
+ if (!signal) return NOOP
6
+ const onAbort = (): void => dispose()
7
+ signal.addEventListener('abort', onAbort, { once: true })
8
+ return () => signal.removeEventListener('abort', onAbort)
9
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AdvertisedSize, Placement, Publisher } from './types.js'
2
+ import { watchAbort } from './abort.js'
2
3
  import {
3
4
  GEOMETRY_PROTOCOL_VERSION,
4
5
  type GeometryAddress,
@@ -14,6 +15,8 @@ export type GeometryBatchSend = Publisher<GeometryBatch>
14
15
  export interface GeometryBatcherOptions {
15
16
  /** Observes every batch-delivery error, including explicit flushes; it must not throw. */
16
17
  onError?: (error: unknown) => void
18
+ /** Stops this batcher when aborted. An already-aborted signal starts no work. */
19
+ signal?: AbortSignal
17
20
  }
18
21
 
19
22
  export interface GeometryBatcher {
@@ -85,8 +88,8 @@ const NOOP_SEND: GeometryBatchSend = () => false
85
88
 
86
89
  /**
87
90
  * Coalesces same-task messages without adding a rendering-frame delay. It owns
88
- * no authorization policy: callers must associate addresses with trusted IPC
89
- * senders before accepting a delivered batch.
91
+ * no authorization policy: callers must associate addresses with trusted
92
+ * sources before accepting a delivered batch.
90
93
  */
91
94
  export function createGeometryBatcher(
92
95
  send: GeometryBatchSend,
@@ -107,6 +110,7 @@ export function createGeometryBatcher(
107
110
  let disposed = false
108
111
  let scheduled = false
109
112
  let flushing = false
113
+ let removeAbortListener = (): void => {}
110
114
 
111
115
  // `activeOptions` lets a caller keep reporting to the options object that
112
116
  // was live when its flush() call started, even if a reentrant dispose()
@@ -192,6 +196,25 @@ export function createGeometryBatcher(
192
196
  })
193
197
  }
194
198
 
199
+ const dispose = (): void => {
200
+ if (disposed) return
201
+ disposed = true
202
+ removeAbortListener()
203
+ removeAbortListener = (): void => {}
204
+ anchors.clear()
205
+ pendingAnchors.clear()
206
+ // Callers may still mutate the original options object after
207
+ // construction (e.g. reassigning onError); flush() captures its own
208
+ // reference before send() runs, so an in-flight error report keeps
209
+ // reading that object even though dispose() drops the instance's
210
+ // long-lived reference here.
211
+ options = {}
212
+ send = NOOP_SEND
213
+ }
214
+
215
+ if (options.signal?.aborted) dispose()
216
+ else removeAbortListener = watchAbort(options.signal, dispose)
217
+
195
218
  return {
196
219
  publish(message) {
197
220
  if (disposed) return false
@@ -238,17 +261,6 @@ export function createGeometryBatcher(
238
261
  if (state !== undefined) pendingAnchors.delete(state)
239
262
  anchors.delete(anchorId)
240
263
  },
241
- dispose() {
242
- disposed = true
243
- anchors.clear()
244
- pendingAnchors.clear()
245
- // Callers may still mutate the original options object after
246
- // construction (e.g. reassigning onError); flush() captures its own
247
- // reference before send() runs, so an in-flight error report keeps
248
- // reading that object even though dispose() drops the instance's
249
- // long-lived reference here.
250
- options = {}
251
- send = NOOP_SEND
252
- },
264
+ dispose,
253
265
  }
254
266
  }
package/src/react.ts CHANGED
@@ -50,14 +50,11 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
50
50
  const handleRef = useRef<Handle | null>(null)
51
51
  const elementRef = useRef<HTMLElement | null>(null)
52
52
  const optionsRef = useRef(options)
53
- // eslint-disable-next-line react-hooks/refs
54
53
  optionsRef.current = options
55
54
  const adapterRef = useRef(adapter)
56
- // eslint-disable-next-line react-hooks/refs
57
55
  adapterRef.current = adapter
58
56
  const appliedRef = useRef(applied)
59
57
  const currentAppliedRef = useRef(applied)
60
- // eslint-disable-next-line react-hooks/refs
61
58
  currentAppliedRef.current = applied
62
59
  // Options handed to the adapter on the last create/update call.
63
60
  // Tracks applied state across renders where the deps array reference changes.
@@ -116,7 +113,6 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
116
113
  return () => deferDetach(element)
117
114
  }
118
115
  return undefined
119
- // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
120
116
  }, [])
121
117
 
122
118
  useEffect(() => {
@@ -131,7 +127,7 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
131
127
  adapterRef.current.update(handle, optionsRef.current)
132
128
  lastAppliedOptionsRef.current = optionsRef.current
133
129
  }
134
- // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ // oxlint-disable-next-line react/exhaustive-deps
135
131
  }, applied)
136
132
 
137
133
  useEffect(() => {
@@ -140,7 +136,6 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
140
136
  const element = elementRef.current
141
137
  if (element) deferDetach(element)
142
138
  }
143
- // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
144
139
  }, [])
145
140
 
146
141
  return ref
@@ -4,6 +4,7 @@ import type {
4
4
  SizeAdvertiserOptions,
5
5
  SizeAdvertiserHandle,
6
6
  } from './types.js'
7
+ import { watchAbort } from './abort.js'
7
8
  import { createMeasureLoop } from './measure-loop.js'
8
9
 
9
10
  // Replaces a disposed instance's publish callback so a retained handle does
@@ -68,9 +69,30 @@ export function createSizeAdvertiser(
68
69
  )
69
70
  }
70
71
 
71
- loop.setActive(true)
72
- observer = new ResizeObserver(onResize)
73
- observer.observe(target)
72
+ let removeAbortListener = (): void => {}
73
+
74
+ const dispose = (): void => {
75
+ if (disposed) return
76
+ disposed = true
77
+ removeAbortListener()
78
+ removeAbortListener = (): void => {}
79
+ loop.cancel()
80
+ if (observer) {
81
+ observer.disconnect()
82
+ observer = null
83
+ }
84
+ loop.dispose()
85
+ publish = NOOP_PUBLISH
86
+ latest = null
87
+ }
88
+
89
+ if (opts.signal?.aborted) dispose()
90
+ else {
91
+ removeAbortListener = watchAbort(opts.signal, dispose)
92
+ loop.setActive(true)
93
+ observer = new ResizeObserver(onResize)
94
+ observer.observe(target)
95
+ }
74
96
 
75
97
  return {
76
98
  update(nextPublish: Publisher<AdvertisedSize>): void {
@@ -81,17 +103,6 @@ export function createSizeAdvertiser(
81
103
  const cur = produce()
82
104
  if (cur !== null) loop.emitNow(cur)
83
105
  },
84
- dispose(): void {
85
- if (disposed) return
86
- disposed = true
87
- loop.cancel()
88
- if (observer) {
89
- observer.disconnect()
90
- observer = null
91
- }
92
- loop.dispose()
93
- publish = NOOP_PUBLISH
94
- latest = null
95
- },
106
+ dispose,
96
107
  }
97
108
  }
package/src/types.ts CHANGED
@@ -36,6 +36,8 @@ export interface ViewAnchorOptions {
36
36
  present: boolean
37
37
  /** Receives the live rect, or zero bounds when detached. */
38
38
  publish: Publisher<Bounds>
39
+ /** Stops this anchor when aborted. An already-aborted signal starts no work. */
40
+ signal?: AbortSignal
39
41
  }
40
42
 
41
43
  export interface ViewAnchorHandle {
@@ -66,6 +68,8 @@ export interface SizeAdvertiserOptions {
66
68
  axis: AdvertisedAxis
67
69
  /** Receives each advertised size. */
68
70
  publish: Publisher<AdvertisedSize>
71
+ /** Stops this advertiser when aborted. An already-aborted signal starts no work. */
72
+ signal?: AbortSignal
69
73
  }
70
74
 
71
75
  export interface SizeAdvertiserHandle {
@@ -1,4 +1,5 @@
1
1
  import type { Bounds, Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from './types.js'
2
+ import { watchAbort } from './abort.js'
2
3
 
3
4
  const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
4
5
 
@@ -27,8 +28,8 @@ const clampRect = (r: { x: number; y: number; width: number; height: number }):
27
28
  * - `dispose()`: stops observing and prevents any further publishes.
28
29
  *
29
30
  * Synchronous publishing: measurement and publishing occur directly in the
30
- * observer tick. Cross-process setBounds calls already have a compositor delay;
31
- * adding requestAnimationFrame would add a second frame of visual lag during drag
31
+ * observer tick. Applying geometry outside the DOM may already be delayed;
32
+ * adding requestAnimationFrame would add another frame of visual lag during drag
32
33
  * operations. High-frequency updates are deduplicated against the last accepted rect.
33
34
  */
34
35
  export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
@@ -46,7 +47,7 @@ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions):
46
47
 
47
48
  const measure = (): Bounds | null => {
48
49
  const r = targetRef!.getBoundingClientRect()
49
- // Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
50
+ // Drop ticks with non-finite values (NaN / Infinity are not usable geometry).
50
51
  if (
51
52
  !Number.isFinite(r.left) ||
52
53
  !Number.isFinite(r.top) ||
@@ -115,7 +116,24 @@ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions):
115
116
  }
116
117
  }
117
118
 
118
- apply()
119
+ let removeAbortListener = (): void => {}
120
+
121
+ const dispose = (): void => {
122
+ if (disposed) return
123
+ disposed = true
124
+ removeAbortListener()
125
+ removeAbortListener = (): void => {}
126
+ stopObserving()
127
+ targetRef = null
128
+ publish = NOOP_PUBLISH
129
+ lastPublished = null
130
+ }
131
+
132
+ if (opts.signal?.aborted) dispose()
133
+ else {
134
+ removeAbortListener = watchAbort(opts.signal, dispose)
135
+ apply()
136
+ }
119
137
 
120
138
  return {
121
139
  update(next: ViewAnchorOptions): void {
@@ -124,14 +142,7 @@ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions):
124
142
  present = next.present
125
143
  apply()
126
144
  },
127
- dispose(): void {
128
- if (disposed) return
129
- disposed = true
130
- stopObserving()
131
- targetRef = null
132
- publish = NOOP_PUBLISH
133
- lastPublished = null
134
- },
145
+ dispose,
135
146
  }
136
147
  }
137
148
 
@@ -145,6 +156,8 @@ export interface PlacementAnchorOptions {
145
156
  visible: boolean
146
157
  /** Receives each explicit Placement. */
147
158
  publish: Publisher<Placement>
159
+ /** Stops this anchor when aborted. An already-aborted signal starts no work. */
160
+ signal?: AbortSignal
148
161
  /**
149
162
  * When true, targets with zero area (such as display: none or unmounted elements)
150
163
  * publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
@@ -485,7 +498,24 @@ export function createPlacementAnchor(
485
498
  }
486
499
  }
487
500
 
488
- apply()
501
+ let removeAbortListener = (): void => {}
502
+
503
+ const dispose = (): void => {
504
+ if (disposed) return
505
+ disposed = true
506
+ removeAbortListener()
507
+ removeAbortListener = (): void => {}
508
+ stopObserving()
509
+ targetRef = null
510
+ publish = NOOP_PUBLISH
511
+ lastPublished = null
512
+ }
513
+
514
+ if (opts.signal?.aborted) dispose()
515
+ else {
516
+ removeAbortListener = watchAbort(opts.signal, dispose)
517
+ apply()
518
+ }
489
519
 
490
520
  return {
491
521
  update(next: PlacementAnchorOptions): void {
@@ -503,14 +533,7 @@ export function createPlacementAnchor(
503
533
  }
504
534
  apply()
505
535
  },
506
- dispose(): void {
507
- if (disposed) return
508
- disposed = true
509
- stopObserving()
510
- targetRef = null
511
- publish = NOOP_PUBLISH
512
- lastPublished = null
513
- },
536
+ dispose,
514
537
  pulse(durationMs?: number): void {
515
538
  if (disposed || !followGeometry) return
516
539
  if (durationMs !== undefined && durationMs > 0) {