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.
- package/README.md +32 -44
- package/README.zh-CN.md +32 -44
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +22 -3
- package/dist/protocol-publisher.d.ts.map +1 -1
- package/dist/protocol-publisher.js +19 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -5
- package/dist/react.d.ts.map +1 -1
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +29 -12
- package/docs/bidirectional-design.md +19 -15
- package/docs/index.html +413 -398
- package/docs/{mechanism.mdx → mechanism.md} +1 -10
- package/docs/performance-report.md +30 -30
- package/docs/protocol.md +31 -2
- package/package.json +9 -12
- package/src/index.ts +2 -9
- package/src/measure-loop.ts +22 -3
- package/src/protocol-publisher.ts +21 -3
- package/src/protocol.ts +8 -20
- package/src/react.ts +3 -12
- package/src/size-advertiser.ts +9 -0
- package/src/types.ts +1 -3
- package/src/view-anchor.ts +41 -34
|
@@ -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
|
-
|
|
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.
|
|
12
|
-
| 逐个 `clear(anchorId)` | 10,000 |
|
|
13
|
-
| 已有 100,000 个历史锚点后只 flush 1 条 | 1 | 0.
|
|
14
|
-
| 发布 placement envelope | 1,000,000 次 |
|
|
15
|
-
| 同一 anchor 连续 publish + flush | 100,000 次 | 8.
|
|
16
|
-
| 解码合法 batch | 100,000 条 |
|
|
17
|
-
| 解码末项非法 batch | 100,000 条 | 3.
|
|
18
|
-
| sequence guard 接收并清理 | 100,000 条 |
|
|
19
|
-
| `measurePlacement` | 1,000,000 次 | 8.
|
|
11
|
+
| 所有锚点进入下一 generation | 10,000 | 1.5440ms | 1.5440、1.5517、1.4217 |
|
|
12
|
+
| 逐个 `clear(anchorId)` | 10,000 | 0.9214ms | 0.9230、0.8706、0.9214 |
|
|
13
|
+
| 已有 100,000 个历史锚点后只 flush 1 条 | 1 | 0.0067ms | 0.0059、0.0073、0.0067 |
|
|
14
|
+
| 发布 placement envelope | 1,000,000 次 | 6.6062ms | 6.3072、6.6649、6.6062 |
|
|
15
|
+
| 同一 anchor 连续 publish + flush | 100,000 次 | 8.4688ms | 8.0620、8.9530、8.4688 |
|
|
16
|
+
| 解码合法 batch | 100,000 条 | 2.7766ms | 2.6953、3.0069、2.7766 |
|
|
17
|
+
| 解码末项非法 batch | 100,000 条 | 3.2230ms | 3.3725、3.2230、2.9416 |
|
|
18
|
+
| sequence guard 接收并清理 | 100,000 条 | 11.5324ms | 11.5672、11.1703、11.5324 |
|
|
19
|
+
| `measurePlacement` | 1,000,000 次 | 8.0442ms | 8.0442、7.9074、8.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,
|
|
30
|
-
| batcher 成功 flush 后 | 13,
|
|
31
|
-
| batcher `clear()` 后 | 34,
|
|
32
|
-
| sequence guard 保留状态 | 14,088,840B | 27,
|
|
33
|
-
| sequence guard `clear()` 后 | 19,568B | 23,
|
|
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
|
-
使用
|
|
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,
|
|
42
|
-
| `view-anchor/protocol` | 3,
|
|
43
|
-
| `view-anchor/react` | 5,
|
|
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` |
|
|
48
|
-
| `createPlacementAnchor` | 3,
|
|
49
|
-
| `measurePlacement` |
|
|
50
|
-
| `createSizeAdvertiser` | 1,
|
|
51
|
-
| `decodeGeometryWireValue` | 1,
|
|
52
|
-
| `createGeometrySequenceGuard` |
|
|
53
|
-
| `createGeometryBatcher` | 1,
|
|
54
|
-
| `createPlacementMessagePublisher` |
|
|
55
|
-
| `createSizeMessagePublisher` |
|
|
56
|
-
| `useViewAnchor` | 2,
|
|
57
|
-
| `usePlacementAnchor` | 4,
|
|
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`
|
|
40
|
-
2. **批处理与重试**。`createGeometryBatcher`
|
|
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.
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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,
|
package/src/measure-loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
[
|
package/src/size-advertiser.ts
CHANGED
|
@@ -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
|
/**
|
package/src/view-anchor.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
)
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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()) {
|
|
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(
|
|
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(
|
|
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
|