view-anchor 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,10 @@
1
- ---
2
- title: view-anchor
3
- description: 将主进程原生视图(如 Electron WebContentsView)与 DOM 元素的几何位置保持同步。
4
- ---
5
-
6
1
  # view-anchor
7
2
 
8
3
  让宿主外部的视图(例如 Electron 的 `WebContentsView`)实时对齐某个 DOM 元素的屏幕位置。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,把矩形数据交给注入的 `publish` 回调(通常由调用方转发 IPC 到 `setBounds`),并在元素移动或缩放时同步更新。
9
4
 
10
5
  核心不依赖 React、Electron 或特定的布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
11
6
 
12
- <iframe
13
- src="./index.html"
14
- title="view-anchor 交互演示"
15
- style={{ width: '100%', height: '560px', border: '0', borderRadius: '12px' }}
16
- />
7
+ > 🎮 [3D 交互演示](https://lbb00.github.io/view-anchor/):页面里跑的是真实核心代码。拖动分栏、切换面板显示,看原生视图实时跟随。源码见 [index.html](./index.html)。
17
8
 
18
9
  ## 运行机制
19
10
 
@@ -8,53 +8,53 @@
8
8
 
9
9
  | 场景 | 数据量 | 当前中位数 | 三进程中位数(ms) |
10
10
  | --- | ---: | ---: | --- |
11
- | 所有锚点进入下一 generation | 10,000 | 1.7407ms | 1.5772、1.7407、1.8334 |
12
- | 逐个 `clear(anchorId)` | 10,000 | 1.0757ms | 1.07571.02411.0794 |
13
- | 已有 100,000 个历史锚点后只 flush 1 条 | 1 | 0.0082ms | 0.0102、0.0068、0.0082 |
14
- | 发布 placement envelope | 1,000,000 次 | 7.2710ms | 7.48527.21137.2710 |
15
- | 同一 anchor 连续 publish + flush | 100,000 次 | 8.8899ms | 8.8899、8.84349.2207 |
16
- | 解码合法 batch | 100,000 条 | 3.0990ms | 3.0990、2.9431、3.2170 |
17
- | 解码末项非法 batch | 100,000 条 | 3.3382ms | 3.3195、3.62823.3382 |
18
- | sequence guard 接收并清理 | 100,000 条 | 12.8928ms | 12.908512.892812.8151 |
19
- | `measurePlacement` | 1,000,000 次 | 8.9269ms | 8.92698.54989.0800 |
11
+ | 所有锚点进入下一 generation | 10,000 | 1.5440ms | 1.5440、1.5517、1.4217 |
12
+ | 逐个 `clear(anchorId)` | 10,000 | 0.9214ms | 0.92300.87060.9214 |
13
+ | 已有 100,000 个历史锚点后只 flush 1 条 | 1 | 0.0067ms | 0.0059、0.0073、0.0067 |
14
+ | 发布 placement envelope | 1,000,000 次 | 6.6062ms | 6.30726.66496.6062 |
15
+ | 同一 anchor 连续 publish + flush | 100,000 次 | 8.4688ms | 8.0620、8.95308.4688 |
16
+ | 解码合法 batch | 100,000 条 | 2.7766ms | 2.6953、3.0069、2.7766 |
17
+ | 解码末项非法 batch | 100,000 条 | 3.2230ms | 3.3725、3.22302.9416 |
18
+ | sequence guard 接收并清理 | 100,000 条 | 11.5324ms | 11.567211.170311.5324 |
19
+ | `measurePlacement` | 1,000,000 次 | 8.0442ms | 8.04427.90748.1189 |
20
20
 
21
21
  ## 内存
22
22
 
23
- 每个状态在独立 Node.js 进程中执行,并在读数前显式 GC。表中为三个独立进程的中位数增量;heap 是 V8 保留堆,RSS 是进程常驻集合大小,二者不应混用。清理对象后,分配器也不一定立即把内存页归还给操作系统,所以 `afterClear` 的 RSS 不能当作仍有等量 JavaScript 对象存活。
23
+ 每个状态在独立 Node.js 进程中执行,并在读数前显式 GC。表中为三个独立进程的中位数增量;heap 是 V8 保留堆,RSS 是进程常驻集合大小,二者不应混用。清理对象后,分配器也不一定立即把内存页归还给操作系统,所以 `afterClear` 的 RSS 不能当作仍有等量 JavaScript 对象存活。`dispose()` 会取消观察并释放目标元素与回调引用;这一行为由独立的 WeakRef 回归测试覆盖,不用下表的 RSS 来判断。
24
24
 
25
25
  性能 harness 将 `queueMicrotask` 替换为 no-op,然后显式调用 `flush()`;这是刻意测量 batcher 的 pending/retained 状态,**不是**真实宿主 microtask 调度路径。
26
26
 
27
27
  | 状态(100,000 个 anchor) | heap 增量 | RSS 增量 |
28
28
  | --- | ---: | ---: |
29
- | batcher 待 flush | 26,320,176B | 45,858,816B |
30
- | batcher 成功 flush 后 | 13,303,872B | 43,696,128B |
31
- | batcher `clear()` 后 | 34,432B | 40,026,112B |
32
- | sequence guard 保留状态 | 14,088,840B | 27,099,136B |
33
- | sequence guard `clear()` 后 | 19,568B | 23,461,888B |
29
+ | batcher 待 flush | 26,320,176B | 45,744,128B |
30
+ | batcher 成功 flush 后 | 13,304,000B | 43,548,672B |
31
+ | batcher `clear()` 后 | 34,560B | 39,895,040B |
32
+ | sequence guard 保留状态 | 14,088,840B | 27,246,592B |
33
+ | sequence guard `clear()` 后 | 19,568B | 23,576,576B |
34
34
 
35
35
  ## 实际导出代码体积
36
36
 
37
- 使用 esbuild bundle、tree-shaking 和 minify,且将 `react` 作为 peer external。测量的是实际执行 JavaScript,不是 npm tarball、source map 或声明文件。
37
+ 使用 Rolldown bundle、tree-shaking 和 minify,且将 `react` 作为 peer external。测量的是实际执行 JavaScript,不是 npm tarball、source map 或声明文件。
38
38
 
39
39
  | 完整入口 | raw | gzip | brotli |
40
40
  | --- | ---: | ---: | ---: |
41
- | `view-anchor` | 6,627B | 2,588B | 2,297B |
42
- | `view-anchor/protocol` | 3,581B | 1,412B | 1,276B |
43
- | `view-anchor/react` | 5,472B | 2,023B | 1,797B |
41
+ | `view-anchor` | 6,728B | 2,545B | 2,252B |
42
+ | `view-anchor/protocol` | 3,573B | 1,378B | 1,243B |
43
+ | `view-anchor/react` | 5,646B | 2,036B | 1,789B |
44
44
 
45
45
  | 单独导出 | raw | gzip | brotli |
46
46
  | --- | ---: | ---: | ---: |
47
- | `createViewAnchor` | 987B | 528B | 472B |
48
- | `createPlacementAnchor` | 3,246B | 1,246B | 1,120B |
49
- | `measurePlacement` | 282B | 196B | 167B |
50
- | `createSizeAdvertiser` | 1,350B | 780B | 680B |
51
- | `decodeGeometryWireValue` | 1,472B | 664B | 570B |
52
- | `createGeometrySequenceGuard` | 396B | 258B | 229B |
53
- | `createGeometryBatcher` | 1,357B | 637B | 579B |
54
- | `createPlacementMessagePublisher` | 200B | 165B | 135B |
55
- | `createSizeMessagePublisher` | 185B | 162B | 129B |
56
- | `useViewAnchor` | 2,116B | 984B | 885B |
57
- | `usePlacementAnchor` | 4,421B | 1,718B | 1,549B |
47
+ | `createViewAnchor` | 1,026B | 544B | 477B |
48
+ | `createPlacementAnchor` | 3,264B | 1,253B | 1,112B |
49
+ | `measurePlacement` | 283B | 195B | 168B |
50
+ | `createSizeAdvertiser` | 1,391B | 782B | 693B |
51
+ | `decodeGeometryWireValue` | 1,456B | 657B | 563B |
52
+ | `createGeometrySequenceGuard` | 395B | 255B | 222B |
53
+ | `createGeometryBatcher` | 1,380B | 639B | 586B |
54
+ | `createPlacementMessagePublisher` | 174B | 151B | 112B |
55
+ | `createSizeMessagePublisher` | 159B | 148B | 110B |
56
+ | `useViewAnchor` | 2,183B | 1,002B | 894B |
57
+ | `usePlacementAnchor` | 4,572B | 1,730B | 1,558B |
58
58
 
59
59
  ## V8 与边界
60
60
 
package/docs/protocol.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  `view-anchor` 核心模块只负责测量并同步调用 `publish`。如果需要在进程或 iframe 边界上传输几何数据,可以使用可选的 `view-anchor/protocol` 模块。它提供带版本的消息结构、输入校验、消息防乱序和微任务批处理,不绑定具体的传输通道。
4
4
 
5
+ 一条消息从锚点走到宿主要经过的环节:
6
+
7
+ ```mermaid
8
+ sequenceDiagram
9
+ participant A as 锚点核心
10
+ participant P as MessagePublisher
11
+ participant B as GeometryBatcher
12
+ participant D as decodeGeometryWireValue
13
+ participant G as SequenceGuard
14
+ participant H as 宿主
15
+
16
+ A->>P: publish(placement)
17
+ P->>B: 带 anchorId、generation、seq 的消息
18
+ Note over B: 同一微任务内合并,<br/>每个 anchorId 的 placement 和 size<br/>各自只留最新一条
19
+ B->>D: IPC / postMessage
20
+ D->>D: 校验版本、类型、整数范围、批次上限
21
+ alt 校验不通过
22
+ D-->>H: 返回 ok: false,不抛异常
23
+ else 校验通过
24
+ Note over D,G: 宿主先验证发送方身份<br/>senderFrame / origin / token
25
+ D->>G: 逐条交给 guard
26
+ alt generation 更旧,或同类型 seq 不大于已见最大值
27
+ G-->>H: 丢弃
28
+ else
29
+ G->>H: applyGeometry(message)
30
+ end
31
+ end
32
+ ```
33
+
5
34
  ## 消息结构
6
35
 
7
36
  协议消息包含以下字段:
@@ -36,8 +65,8 @@ const publish = createPlacementMessagePublisher(
36
65
 
37
66
  使用 publisher 时需注意两点:
38
67
 
39
- 1. **每个 `{ anchorId, generation }` 保持单个 publisher 实例**。在 React 中应使用 `useMemo` 或 `useRef` 保存,不要在每次渲染时重新创建。接收端的 `createGeometrySequenceGuard` 会记录见过的最大序列号;如果在同一代次下重建 publisher,序列号会从 1 重新计数,导致新发出的消息被当成过期消息丢弃。确实需要重置时,请将 `generation` 加一。
40
- 2. **批处理与重试**。`createGeometryBatcher` 会在当前微任务中合并同一事件循环内的多次更新,每个锚点只保留最新的一条。如果下游发送失败或抛出异常,未成功发送的消息会保留在队列中,等待下次调用或显式 `flush()` 时重试。
68
+ 1. **每个 `{ anchorId, generation }` 保持单个 publisher 实例**。在 React 中应使用 `useMemo` 或 `useRef` 保存,不要在每次渲染时重新创建。接收端的 `createGeometrySequenceGuard` 会按 `placement` 和 `size` 分别记录见过的最大序列号;如果在同一代次下重建 publisher,序列号会从 1 重新计数,导致新发出的消息被当成过期消息丢弃。确实需要重置时,请将 `generation` 加一。
69
+ 2. **批处理与重试**。`createGeometryBatcher` 会在当前微任务中合并同一事件循环内的多次更新,每个锚点的 `placement` 和 `size` 各自只保留最新的一条。如果下游发送失败或抛出异常,未成功发送的消息会保留在队列中,等待下次调用或显式 `flush()` 时重试。
41
70
 
42
71
  当锚点销毁时,可以调用 `batcher.clear(anchorId)` 清理对应队列;调用 `dispose()` 会彻底停用批处理器。
43
72
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "view-anchor",
3
- "version": "0.2.0",
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. ~2.6 KB gzipped, framework-free core.",
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.",
5
5
  "keywords": [
6
6
  "electron",
7
7
  "webcontentsview",
@@ -72,23 +72,18 @@
72
72
  }
73
73
  },
74
74
  "devDependencies": {
75
- "@eslint/js": "^10.0.1",
76
75
  "@testing-library/react": "^16.3.2",
76
+ "@types/node": "^26.5.1",
77
77
  "@types/react": "^18.3.12",
78
78
  "@vitejs/plugin-react": "^6.0.1",
79
79
  "@vitest/coverage-v8": "^4.1.4",
80
- "esbuild": "^0.28.1",
81
- "eslint": "^10.2.1",
82
- "eslint-config-prettier": "^10.1.8",
83
- "eslint-plugin-only-warn": "^1.2.1",
84
- "eslint-plugin-react": "^7.37.5",
85
- "eslint-plugin-react-hooks": "^7.1.1",
86
- "globals": "^17.5.0",
87
80
  "jsdom": "^29.0.2",
81
+ "oxfmt": "^0.67.0",
82
+ "oxlint": "^1.82.0",
88
83
  "react": "^18.3.1",
89
84
  "react-dom": "^18.3.1",
85
+ "rolldown": "1.2.7",
90
86
  "typescript": "5.9.2",
91
- "typescript-eslint": "^8.58.2",
92
87
  "vitest": "^4.1.4"
93
88
  },
94
89
  "publishConfig": {
@@ -98,7 +93,9 @@
98
93
  "build": "tsc -p tsconfig.build.json && pnpm run build:docs",
99
94
  "build:docs": "node scripts/build-docs.mjs",
100
95
  "check-types": "tsc --noEmit",
101
- "lint": "eslint . --max-warnings 0",
96
+ "lint": "oxlint . --deny-warnings",
97
+ "format": "oxfmt --write .",
98
+ "format:check": "oxfmt --check .",
102
99
  "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
103
100
  "test:dev": "vitest",
104
101
  "test:coverage": "vitest run --coverage",
package/src/index.ts CHANGED
@@ -2,15 +2,8 @@
2
2
  * view-anchor: keeps an external surface aligned with a DOM element's geometry.
3
3
  */
4
4
 
5
- export {
6
- createViewAnchor,
7
- measurePlacement,
8
- createPlacementAnchor,
9
- } from './view-anchor.js'
10
- export type {
11
- PlacementAnchorOptions,
12
- PlacementAnchorHandle,
13
- } from './view-anchor.js'
5
+ export { createViewAnchor, measurePlacement, createPlacementAnchor } from './view-anchor.js'
6
+ export type { PlacementAnchorOptions, PlacementAnchorHandle } from './view-anchor.js'
14
7
  export type {
15
8
  Bounds,
16
9
  Placement,
@@ -14,6 +14,13 @@ export interface MeasureLoop<T> {
14
14
  dispose(): void
15
15
  }
16
16
 
17
+ // Terminal-state stand-ins for cfg.produce/same/sink so a retained handle's
18
+ // dispose()d loop does not keep the original closures (or what they
19
+ // captured) alive. Guarded call sites never actually reach these.
20
+ const NOOP_PRODUCE = (): null => null
21
+ const NOOP_SAME = (): true => true
22
+ const NOOP_SINK = (): false => false
23
+
17
24
  export function createMeasureLoop<T>(cfg: {
18
25
  /**
19
26
  * Produce the value to emit in the animation frame. Return null to skip
@@ -23,7 +30,9 @@ export function createMeasureLoop<T>(cfg: {
23
30
  same: (a: T, b: T) => boolean
24
31
  sink: import('./types.js').Publisher<T>
25
32
  }): MeasureLoop<T> {
26
- const { produce, same, sink } = cfg
33
+ let produce = cfg.produce
34
+ let same = cfg.same
35
+ let sink = cfg.sink
27
36
  let rafId: number | null = null
28
37
  let active = false
29
38
  let disposed = false
@@ -31,15 +40,21 @@ export function createMeasureLoop<T>(cfg: {
31
40
  let publicationRevision = 0
32
41
 
33
42
  const deliver = (value: T): boolean => {
43
+ // A reentrant dispose() from produce()/same() (invoked by frame() just
44
+ // before this call) already cleared `last`; do not let this delivery
45
+ // attempt write over that terminal state.
46
+ if (disposed) return false
34
47
  const previous = last
35
48
  const attempt = ++publicationRevision
36
49
  last = value
37
50
  try {
38
51
  const accepted = sink(value) !== false
39
- if (!accepted && publicationRevision === attempt) last = previous
52
+ // A reentrant dispose() during sink() already cleared `last`; do not
53
+ // resurrect the pre-dispose value over that terminal state.
54
+ if (!accepted && publicationRevision === attempt && !disposed) last = previous
40
55
  return accepted
41
56
  } catch (error) {
42
- if (publicationRevision === attempt) last = previous
57
+ if (publicationRevision === attempt && !disposed) last = previous
43
58
  throw error
44
59
  }
45
60
  }
@@ -77,6 +92,10 @@ export function createMeasureLoop<T>(cfg: {
77
92
  if (disposed) return
78
93
  disposed = true
79
94
  cancel()
95
+ produce = NOOP_PRODUCE
96
+ same = NOOP_SAME
97
+ sink = NOOP_SINK
98
+ last = null
80
99
  },
81
100
  }
82
101
  }
@@ -79,6 +79,10 @@ export function createSizeMessagePublisher(
79
79
  }
80
80
  }
81
81
 
82
+ // Terminal-state stand-in for `send` so a retained, disposed batcher does not
83
+ // keep the caller's transport closure (and whatever it captured) alive.
84
+ const NOOP_SEND: GeometryBatchSend = () => false
85
+
82
86
  /**
83
87
  * Coalesces same-task messages without adding a rendering-frame delay. It owns
84
88
  * no authorization policy: callers must associate addresses with trusted IPC
@@ -104,9 +108,12 @@ export function createGeometryBatcher(
104
108
  let scheduled = false
105
109
  let flushing = false
106
110
 
107
- function report(error: unknown): void {
111
+ // `activeOptions` lets a caller keep reporting to the options object that
112
+ // was live when its flush() call started, even if a reentrant dispose()
113
+ // during send() has since cleared the instance-level `options` reference.
114
+ function report(activeOptions: GeometryBatcherOptions, error: unknown): void {
108
115
  try {
109
- options.onError?.(error)
116
+ activeOptions.onError?.(error)
110
117
  } catch {
111
118
  // Error reporting must not turn scheduled delivery into an unhandled error.
112
119
  }
@@ -134,13 +141,17 @@ export function createGeometryBatcher(
134
141
  messages,
135
142
  }
136
143
 
144
+ // Captured before send() runs so a reentrant dispose() (which clears the
145
+ // instance-level `options`) cannot blind this call's own error report.
146
+ const activeOptions = options
147
+
137
148
  flushing = true
138
149
  try {
139
150
  let accepted: boolean
140
151
  try {
141
152
  accepted = send(batch) !== false
142
153
  } catch (error) {
143
- report(error)
154
+ report(activeOptions, error)
144
155
  return false
145
156
  }
146
157
  if (!accepted) return false
@@ -231,6 +242,13 @@ export function createGeometryBatcher(
231
242
  disposed = true
232
243
  anchors.clear()
233
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
234
252
  },
235
253
  }
236
254
  }
package/src/protocol.ts CHANGED
@@ -67,10 +67,7 @@ function decodeSize(value: unknown): AdvertisedSize | undefined {
67
67
  function decodeMessage(value: unknown): GeometryMessage | undefined {
68
68
  if (!isRecord(value) || value.v !== GEOMETRY_PROTOCOL_VERSION) return undefined
69
69
  if (typeof value.anchorId !== 'string' || value.anchorId.length === 0) return undefined
70
- if (
71
- !isNonNegativeSafeInteger(value.generation) ||
72
- !isNonNegativeSafeInteger(value.seq)
73
- ) {
70
+ if (!isNonNegativeSafeInteger(value.generation) || !isNonNegativeSafeInteger(value.seq)) {
74
71
  return undefined
75
72
  }
76
73
  const address = {
@@ -81,9 +78,7 @@ function decodeMessage(value: unknown): GeometryMessage | undefined {
81
78
  }
82
79
  if (value.kind === 'placement') {
83
80
  const placement = decodePlacement(value.placement)
84
- return placement === undefined
85
- ? undefined
86
- : { ...address, kind: 'placement', placement }
81
+ return placement === undefined ? undefined : { ...address, kind: 'placement', placement }
87
82
  }
88
83
  if (value.kind === 'size') {
89
84
  const size = decodeSize(value.size)
@@ -135,11 +130,7 @@ export interface GeometrySequenceGuard {
135
130
  }
136
131
 
137
132
  // -1 means that this generation has not seen the corresponding message kind.
138
- type GeometrySequenceState = [
139
- generation: number,
140
- placementSeq: number,
141
- sizeSeq: number,
142
- ]
133
+ type GeometrySequenceState = [generation: number, placementSeq: number, sizeSeq: number]
143
134
 
144
135
  /**
145
136
  * Keeps one generation floor per anchor and a sequence high-water mark per
@@ -154,14 +145,11 @@ export function createGeometrySequenceGuard(): GeometrySequenceGuard {
154
145
  accept(message) {
155
146
  const current = latest.get(message.anchorId)
156
147
  if (current === undefined || message.generation > current[0]) {
157
- latest.set(
158
- message.anchorId,
159
- [
160
- message.generation,
161
- message.kind === 'placement' ? message.seq : -1,
162
- message.kind === 'size' ? message.seq : -1,
163
- ],
164
- )
148
+ latest.set(message.anchorId, [
149
+ message.generation,
150
+ message.kind === 'placement' ? message.seq : -1,
151
+ message.kind === 'size' ? message.seq : -1,
152
+ ])
165
153
  return true
166
154
  }
167
155
  if (message.generation < current[0]) return false
package/src/react.ts CHANGED
@@ -5,11 +5,7 @@ import {
5
5
  type PlacementAnchorHandle,
6
6
  type PlacementAnchorOptions,
7
7
  } from './view-anchor.js'
8
- import type {
9
- Bounds,
10
- ViewAnchorHandle,
11
- ViewAnchorOptions,
12
- } from './types.js'
8
+ import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
13
9
 
14
10
  export interface UseViewAnchorOptions extends ViewAnchorOptions {
15
11
  /**
@@ -172,10 +168,7 @@ export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
172
168
  )
173
169
  }
174
170
 
175
- const placementAdapter: LifecycleAdapter<
176
- PlacementAnchorOptions,
177
- PlacementAnchorHandle
178
- > = {
171
+ const placementAdapter: LifecycleAdapter<PlacementAnchorOptions, PlacementAnchorHandle> = {
179
172
  create: createPlacementAnchor,
180
173
  update(handle, options) {
181
174
  // In React, an omitted option represents "off" for that render,
@@ -196,9 +189,7 @@ const placementAdapter: LifecycleAdapter<
196
189
  }
197
190
 
198
191
  /** Bind the explicit Placement API to a DOM element callback ref. */
199
- export function usePlacementAnchor(
200
- options: UsePlacementAnchorOptions,
201
- ): PlacementAnchorRef {
192
+ export function usePlacementAnchor(options: UsePlacementAnchorOptions): PlacementAnchorRef {
202
193
  return useAnchorRef(
203
194
  options,
204
195
  [
@@ -6,6 +6,10 @@ import type {
6
6
  } from './types.js'
7
7
  import { createMeasureLoop } from './measure-loop.js'
8
8
 
9
+ // Replaces a disposed instance's publish callback so a retained handle does
10
+ // not keep the caller's original callback (and whatever it captured) alive.
11
+ const NOOP_PUBLISH = (): false => false
12
+
9
13
  /**
10
14
  * Report content size for a single axis back to the host.
11
15
  *
@@ -44,6 +48,9 @@ export function createSizeAdvertiser(
44
48
  })
45
49
 
46
50
  const onResize: ResizeObserverCallback = (entries) => {
51
+ // A callback queued before disconnect() can still fire once more; do not
52
+ // let it write `latest` after dispose() has already cleared it.
53
+ if (disposed) return
47
54
  const entry = entries[entries.length - 1]
48
55
  if (entry) {
49
56
  latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest
@@ -83,6 +90,8 @@ export function createSizeAdvertiser(
83
90
  observer = null
84
91
  }
85
92
  loop.dispose()
93
+ publish = NOOP_PUBLISH
94
+ latest = null
86
95
  },
87
96
  }
88
97
  }
package/src/types.ts CHANGED
@@ -25,9 +25,7 @@ export type Publisher<T> = (value: T) => PublishResult
25
25
  * Distinguishes an intentionally visible but zero-sized element ({ visible: true, bounds: 0x0 })
26
26
  * from a hidden or detached element ({ visible: false }).
27
27
  */
28
- export type Placement =
29
- | { visible: true; bounds: Bounds }
30
- | { visible: false }
28
+ export type Placement = { visible: true; bounds: Bounds } | { visible: false }
31
29
 
32
30
  export interface ViewAnchorOptions {
33
31
  /**
@@ -1,22 +1,15 @@
1
- import type {
2
- Bounds,
3
- Placement,
4
- Publisher,
5
- ViewAnchorOptions,
6
- ViewAnchorHandle,
7
- } from './types.js'
1
+ import type { Bounds, Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from './types.js'
8
2
 
9
3
  const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
10
4
 
5
+ // Replaces a disposed instance's publish callback so a retained handle does
6
+ // not keep the caller's original callback (and whatever it captured) alive.
7
+ const NOOP_PUBLISH = (): false => false
8
+
11
9
  // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
12
10
  // a collapsed rect). Coordinates (x, y) can be negative when an element is
13
11
  // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
14
- const clampRect = (r: {
15
- x: number
16
- y: number
17
- width: number
18
- height: number
19
- }): Bounds => ({
12
+ const clampRect = (r: { x: number; y: number; width: number; height: number }): Bounds => ({
20
13
  x: Math.round(r.x),
21
14
  y: Math.round(r.y),
22
15
  width: Math.max(0, Math.round(r.width)),
@@ -38,13 +31,13 @@ const clampRect = (r: {
38
31
  * adding requestAnimationFrame would add a second frame of visual lag during drag
39
32
  * operations. High-frequency updates are deduplicated against the last accepted rect.
40
33
  */
41
- export function createViewAnchor(
42
- target: HTMLElement,
43
- opts: ViewAnchorOptions,
44
- ): ViewAnchorHandle {
34
+ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
45
35
  let present = opts.present
46
36
  let publish = opts.publish
47
37
  let observer: ResizeObserver | null = null
38
+ // Local, clearable alias for the `target` parameter so dispose() can drop
39
+ // the strong reference without widening the public parameter's type.
40
+ let targetRef: HTMLElement | null = target
48
41
  // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
49
42
  // force a re-publish even if the geometry did not change.
50
43
  let lastPublished: Bounds | null = null
@@ -52,14 +45,15 @@ export function createViewAnchor(
52
45
  let disposed = false
53
46
 
54
47
  const measure = (): Bounds | null => {
55
- const r = target.getBoundingClientRect()
48
+ const r = targetRef!.getBoundingClientRect()
56
49
  // Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
57
50
  if (
58
51
  !Number.isFinite(r.left) ||
59
52
  !Number.isFinite(r.top) ||
60
53
  !Number.isFinite(r.width) ||
61
54
  !Number.isFinite(r.height)
62
- ) return null
55
+ )
56
+ return null
63
57
  return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
64
58
  }
65
59
 
@@ -72,10 +66,12 @@ export function createViewAnchor(
72
66
  lastPublished = candidate
73
67
  try {
74
68
  const accepted = publish(candidate) !== false
75
- if (!accepted && publicationRevision === attempt) lastPublished = previous
69
+ // A reentrant dispose() during publish() already cleared lastPublished;
70
+ // do not resurrect the pre-dispose value over that terminal state.
71
+ if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
76
72
  return accepted
77
73
  } catch (error) {
78
- if (publicationRevision === attempt) lastPublished = previous
74
+ if (publicationRevision === attempt && !disposed) lastPublished = previous
79
75
  throw error
80
76
  }
81
77
  }
@@ -93,7 +89,7 @@ export function createViewAnchor(
93
89
  const startObserving = (): void => {
94
90
  if (observer) return
95
91
  observer = new ResizeObserver(emit)
96
- observer.observe(target)
92
+ observer.observe(targetRef!)
97
93
  window.addEventListener('resize', emit)
98
94
  }
99
95
 
@@ -132,6 +128,9 @@ export function createViewAnchor(
132
128
  if (disposed) return
133
129
  disposed = true
134
130
  stopObserving()
131
+ targetRef = null
132
+ publish = NOOP_PUBLISH
133
+ lastPublished = null
135
134
  },
136
135
  }
137
136
  }
@@ -220,6 +219,9 @@ export function createPlacementAnchor(
220
219
  let guardDisplayNone = opts.guardDisplayNone ?? false
221
220
  let followScroll = opts.followScroll ?? false
222
221
  let followGeometry = opts.followGeometry ?? false
222
+ // Local, clearable alias for the `target` parameter so dispose() can drop
223
+ // the strong reference without widening the public parameter's type.
224
+ let targetRef: HTMLElement | null = target
223
225
  let observer: ResizeObserver | null = null
224
226
  let io: IntersectionObserver | null = null
225
227
  let scrollListening = false
@@ -245,19 +247,16 @@ export function createPlacementAnchor(
245
247
  let sentinelDeadline: number | null = null
246
248
 
247
249
  const computePlacement = (): Placement | null => {
248
- const p = measurePlacement(target)
250
+ const p = measurePlacement(targetRef!)
249
251
  if (
250
252
  p.visible &&
251
253
  (!Number.isFinite(p.bounds.x) ||
252
254
  !Number.isFinite(p.bounds.y) ||
253
255
  !Number.isFinite(p.bounds.width) ||
254
256
  !Number.isFinite(p.bounds.height))
255
- ) return null
256
- if (
257
- guardDisplayNone &&
258
- p.visible &&
259
- (p.bounds.width === 0 || p.bounds.height === 0)
260
- ) {
257
+ )
258
+ return null
259
+ if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
261
260
  return { visible: false }
262
261
  }
263
262
  return p
@@ -269,10 +268,12 @@ export function createPlacementAnchor(
269
268
  lastPublished = candidate
270
269
  try {
271
270
  const accepted = publish(candidate) !== false
272
- if (!accepted && publicationRevision === attempt) lastPublished = previous
271
+ // A reentrant dispose() during publish() already cleared lastPublished;
272
+ // do not resurrect the pre-dispose value over that terminal state.
273
+ if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
273
274
  return accepted
274
275
  } catch (error) {
275
- if (publicationRevision === attempt) lastPublished = previous
276
+ if (publicationRevision === attempt && !disposed) lastPublished = previous
276
277
  throw error
277
278
  }
278
279
  }
@@ -315,7 +316,10 @@ export function createPlacementAnchor(
315
316
  }
316
317
  invalidFrames = 0
317
318
  if (!p.visible) {
318
- if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
319
+ if (shouldCloseOnHiddenPoll()) {
320
+ sentinelDeadline = null
321
+ return
322
+ }
319
323
  if (!disposed && visible && followGeometry) {
320
324
  rafId = requestAnimationFrame(sentinelFrame)
321
325
  } else {
@@ -398,7 +402,7 @@ export function createPlacementAnchor(
398
402
  const startOptionalObserving = (): void => {
399
403
  if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
400
404
  io = new IntersectionObserver(emit)
401
- io.observe(target)
405
+ io.observe(targetRef!)
402
406
  }
403
407
  if (followScroll && !scrollListening) {
404
408
  window.addEventListener('scroll', onScroll, passiveCapture)
@@ -454,7 +458,7 @@ export function createPlacementAnchor(
454
458
  const startObserving = (): void => {
455
459
  if (observer) return
456
460
  observer = new ResizeObserver(emit)
457
- observer.observe(target)
461
+ observer.observe(targetRef!)
458
462
  window.addEventListener('resize', emit)
459
463
  startOptionalObserving()
460
464
  }
@@ -503,6 +507,9 @@ export function createPlacementAnchor(
503
507
  if (disposed) return
504
508
  disposed = true
505
509
  stopObserving()
510
+ targetRef = null
511
+ publish = NOOP_PUBLISH
512
+ lastPublished = null
506
513
  },
507
514
  pulse(durationMs?: number): void {
508
515
  if (disposed || !followGeometry) return