view-anchor 0.2.2 → 1.0.0-beta.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 (46) hide show
  1. package/README.md +71 -87
  2. package/README.zh-CN.md +59 -64
  3. package/dist/index.d.ts +5 -7
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -3
  6. package/dist/protocol-publisher.d.ts +7 -9
  7. package/dist/protocol-publisher.d.ts.map +1 -1
  8. package/dist/protocol-publisher.js +2 -8
  9. package/dist/protocol-types.d.ts +5 -8
  10. package/dist/protocol-types.d.ts.map +1 -1
  11. package/dist/protocol-types.js +3 -6
  12. package/dist/protocol.d.ts +2 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js +10 -2
  15. package/dist/react.d.ts +2 -15
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +43 -38
  18. package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
  19. package/dist/size-anchor.d.ts.map +1 -0
  20. package/dist/size-anchor.js +131 -0
  21. package/dist/types.d.ts +26 -32
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/view-anchor.d.ts +52 -33
  24. package/dist/view-anchor.d.ts.map +1 -1
  25. package/dist/view-anchor.js +186 -265
  26. package/docs/bidirectional-design.md +29 -34
  27. package/docs/index.html +656 -366
  28. package/docs/mechanism.md +35 -57
  29. package/docs/performance-report.md +12 -54
  30. package/docs/protocol.md +12 -3
  31. package/package.json +4 -6
  32. package/src/index.ts +5 -19
  33. package/src/protocol-publisher.ts +10 -16
  34. package/src/protocol-types.ts +5 -8
  35. package/src/protocol.ts +13 -7
  36. package/src/react.ts +51 -64
  37. package/src/size-anchor.ts +131 -0
  38. package/src/types.ts +29 -37
  39. package/src/view-anchor.ts +221 -287
  40. package/dist/measure-loop.d.ts +0 -24
  41. package/dist/measure-loop.d.ts.map +0 -1
  42. package/dist/measure-loop.js +0 -89
  43. package/dist/size-advertiser.d.ts.map +0 -1
  44. package/dist/size-advertiser.js +0 -94
  45. package/src/measure-loop.ts +0 -101
  46. package/src/size-advertiser.ts +0 -108
package/docs/mechanism.md CHANGED
@@ -1,86 +1,67 @@
1
1
  # view-anchor
2
2
 
3
- 将外部画面实时对齐到指定的 DOM 元素。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,并把矩形数据交给 `publish` 回调;应用代码决定如何使用这个矩形。
3
+ 将外部画面实时对齐到指定的 DOM 元素。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,并把 `Placement` 交给 `publish` 回调;应用代码决定如何使用它。
4
4
 
5
5
  核心不依赖 React、特定宿主或布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
6
6
 
7
- > **在线演示**:[3D 交互演示](https://lbb00.github.io/view-anchor/) 运行真实核心代码。拖动分栏、切换面板显示,看外部视图实时跟随。源码见 [index.html](./index.html)。
7
+ > **在线演示**:[3D 交互演示](https://lbb00.github.io/view-anchor/) 运行真实核心代码。直接在 3D 场景里拖动分隔条、滚动页面、点击按钮:画面 A 跟随占位元素;画面 B 把自己的内容高度报回页面,页面据此调整它的占位。每个操作对应一项能力,可关掉 `followGeometry` / `followScroll` 对比不跟随时的错位。源码见 [index.html](./index.html)。
8
8
 
9
9
  ## 运行机制
10
10
 
11
11
  ```mermaid
12
12
  flowchart LR
13
13
  DIV["占位元素\n参与 DOM 布局"] -->|"getBoundingClientRect()"| VA["view-anchor"]
14
- VA -->|"publish(bounds)"| A["应用提供的回调"]
15
- A -->|"应用矩形"| S["外部画面"]
14
+ VA -->|"publish(placement)"| A["应用提供的回调"]
15
+ A -->|"应用可见性与矩形"| S["外部画面"]
16
16
  ```
17
17
 
18
- 占位元素参与 DOM 布局,外部画面由应用代码定位。`view-anchor` 只负责测量和调用 `publish(bounds)`;它不创建外部画面,也不决定矩形通过什么方式到达那里。
18
+ 占位元素参与 DOM 布局,外部画面由应用代码定位。`view-anchor` 只负责测量和调用 `publish(placement)`;它不创建外部画面,也不决定数据通过什么方式到达那里。
19
19
 
20
20
  ## createViewAnchor(target, opts)
21
21
 
22
- 命令式核心接口,将外部画面绑定到目标元素,返回 `{ update, dispose }`。
22
+ 命令式核心接口,将外部画面绑定到目标元素,返回 `{ update, pulse, dispose }`。发布的值是一个显式可见性的判别联合类型 `Placement`,区分“确实可见但尺寸为 0x0”和“隐藏或未挂载”这两种情况:
23
23
 
24
24
  ```ts
25
25
  const handle = createViewAnchor(target, {
26
- present: true,
27
- publish(bounds) {
28
- applyBounds(bounds)
26
+ visible: true, // 显式控制可见性
27
+ publish(placement) {
28
+ if (placement.visible) applyBounds(placement.bounds)
29
+ else hideSurface()
29
30
  },
30
31
  })
31
32
  ```
32
33
 
33
- | 状态 / 操作 | 行为 |
34
+ | 选项 / 操作 | 行为 |
34
35
  |---|---|
35
- | `present: true` | 立即发布测量矩形,之后每次 `ResizeObserver` 触发或窗口 `resize` 时同步重发。 |
36
- | `present: false` | 停止观察,发布一次 `{ x: 0, y: 0, width: 0, height: 0 }`。 |
37
- | `update(opts)` | 应用新选项,重置去重缓存并立即重新发布一次。 |
36
+ | `visible: true` | 立即发布 `{ visible: true, bounds }`,之后每次 `ResizeObserver` 触发或窗口 `resize` 时同步重新测量。 |
37
+ | `visible: false` | 发布 `{ visible: false }`,停止观察。`publish` 拒绝时不会自动重发,再次调用 `update()` 才会重发。 |
38
+ | `dedupe`(默认 true) | 与上一次接受的 `Placement` 完全相同(含 `visible` 状态)时跳过发布;传 `false` 则每次测量都调用 `publish`,即使值不变。 |
39
+ | `treatZeroAreaAsHidden`(默认 false) | 开启后,测出零面积的元素发布 `{ visible: false }`,并挂载 `IntersectionObserver` 捕捉 `display: none` 切换。 |
40
+ | `followScroll`(默认 false) | 在捕获阶段监听 `window` 的 `scroll` 事件,祖先容器滚动时重新测量。 |
41
+ | `followGeometry`(默认 false) | 在需要时启动单帧 RAF 轮询,捕获没有 DOM 事件的祖先 transform 或布局位移;几何稳定后自动停止。也可通过 `pulse()` 手动触发。 |
42
+ | `holdSelector`(默认 `[role="separator"]`) | 仅在 `followGeometry` 开启且 `holdSelector` 非空时挂载指针监听。捕获阶段 pointerdown 的目标命中 `closest(holdSelector)` 时打开 RAF 窗口并保持到该指针松开。传 `null` 可关闭;非法选择器会在调用处同步抛出 SyntaxError。 |
43
+ | `update(opts)` | 应用一份完整的新选项并立即重新测量一次;省略的选项一律重置为其默认值(与创建时相同),不会保留上一次的设置。 |
38
44
  | `dispose()` / `AbortController.abort()` | 停止所有监听并释放资源,此后不再发布。 |
39
45
 
40
- 测量结果使用 `Math.round` 取整。`width` 和 `height` 会限制为 `>= 0`(0 代表收起),但 `x` 和 `y` 允许为负数。当元素滚动出视口上边缘或左边缘时,原点自然会是负值,保留负值可以让视图正常跟随元素滚出屏幕。
41
-
42
- **为什么同步发布而不走 RAF:** 外部画面的更新本身可能已经有延迟。若测量后再等一帧,拖拽时会多出一帧跟随延迟。在 observer 回调中直接测量并调用 `publish` 可以避免这一步等待。同帧多次触发时,只要 4 个矩形数值有一项发生变化便会调用 `publish`,与上一帧完全一致则跳过。调用 `update` 会清除去重基线,因此即使矩形不变,也能把新的应用状态重新交给回调。
43
-
44
- 调用 `dispose()`、`AbortController.abort()` 或更新为 `present: false` 后,内部状态会立即置为停用,后续任何异步触发都会直接返回,避免过期数据覆盖新状态。
46
+ 测量结果使用 `Math.round` 取整。`width` 和 `height` 会限制为 `>= 0`,但 `x` 和 `y` 允许为负数。当元素滚动出视口上边缘或左边缘时,原点自然会是负值,保留负值可以让视图正常跟随元素滚出屏幕。测出的 `left`/`top`/`width`/`height` 若含 `NaN` 或 `Infinity`,这一帧会被丢弃、不调用 `publish`,保留上一个有效基线。
45
47
 
46
- ## 收起与零矩形
48
+ 同步发布的原因:外部画面的更新本身可能已经有延迟。若测量后再等一帧,拖拽时会多出一帧跟随延迟。在 observer 回调中直接测量并调用 `publish` 可以避免这一步等待。
47
49
 
48
- - **`present`**:标识外部画面当前是否需要显示。
49
- - **`{ x: 0, y: 0, width: 0, height: 0 }`(零矩形)**:收起信号。应用可以把它解释为隐藏、移除,或仅保留最后一个状态。
50
- - **dispose 行为**:`dispose()` 只停止监听,不补发零矩形。如果需要在元素移除时通知宿主收起视图,应当在 dispose 之前调用 `update({ present: false, ... })`,React 适配层已自动处理了该生命周期。
50
+ 去重(`dedupe: true`):同一帧或连续多次触发中,只要新的 `Placement` 与上一次成功接受的值不完全相同就会调用 `publish`;完全相同则跳过。`update()` 会立即强制重新发布一次,即使矩形不变,也能把新的应用状态重新交给回调。需要每次测量都收到通知(例如驱动一个自身也做节流/采样的下游)时,传 `dedupe: false`:这时每次 `ResizeObserver`/`resize`/`scroll` 触发、以及 `followGeometry` 轮询到的每一帧可见且有效的测量,都会调用 `publish`,隐藏帧仍然不会被发布。
51
51
 
52
- ## 显式可见性(createPlacementAnchor)
53
-
54
- `createViewAnchor` 使用零矩形表示收起,无法区分“隐藏”与“元素确实存在但尺寸为 0x0”的情况。`createPlacementAnchor` 采用显式的 `Placement` 判别联合类型:
55
-
56
- ```ts
57
- const handle = createPlacementAnchor(target, {
58
- visible: true, // 显式控制可见性
59
- publish: (placement) => { ... },
60
- })
61
- ```
62
-
63
- | 选项 / 操作 | 行为 |
64
- |---|---|
65
- | `visible: true` | 发布 `{ visible: true, bounds }`,并在尺寸变化时同步重发。 |
66
- | `visible: false` | 发布 `{ visible: false }`,停止观察。 |
67
- | `guardDisplayNone`(默认 false) | 开启后,测出零面积的元素发布 `{ visible: false }`,并挂载 `IntersectionObserver` 捕捉 `display: none` 切换。 |
68
- | `followScroll`(默认 false) | 在捕获阶段监听 `window` 的 `scroll` 事件,祖先容器滚动时重新测量。 |
69
- | `followGeometry`(默认 false) | 在需要时启动单帧 RAF 轮询,捕获没有 DOM 事件的祖先 transform 或布局位移;几何稳定后自动停止。也可通过 `pulse()` 手动触发。 |
70
-
71
- 去重逻辑会同时对比 `visible` 状态,因此可见性切换不会被同值去重误吞。
52
+ 调用 `dispose()`、`AbortController.abort()` 或更新为 `visible: false` 后,内部状态会立即置为停用,后续任何异步触发都会直接返回,避免过期数据覆盖新状态。
72
53
 
73
54
  ## useViewAnchor(opts)
74
55
 
75
- React 适配层,从 `view-anchor/react` 导出,返回用于挂载在占位元素上的 ref 回调。
56
+ React 适配层,从 `view-anchor/react` 导出,将 `createViewAnchor` 的选项接入 React ref 生命周期,返回用于挂载在占位元素上的 ref 回调。
76
57
 
77
58
  ```tsx
78
59
  import { useViewAnchor } from 'view-anchor/react'
79
60
 
80
61
  const ref = useViewAnchor({
81
- present, // boolean
82
- publish, // Publisher<Bounds>,同步返回 false 表示未接收
83
- deps: [signature], // 可选:影响位置但不会触发 ResizeObserver 的外部依赖
62
+ visible, // boolean
63
+ publish, // Publisher<Placement>,同步返回 false 表示未接收
64
+ deps: [signature], // 可选:影响位置但不会触发 ResizeObserver 的外部依赖
84
65
  })
85
66
  return <div ref={ref} />
86
67
  ```
@@ -89,27 +70,24 @@ return <div ref={ref} />
89
70
  |---|---|
90
71
  | 挂载 | 创建 anchor 实例并开始监听 |
91
72
  | `opts` 或 `deps` 变化 | 调用 `update` 更新参数 |
92
- | 卸载 | 在 microtask 内发布零矩形并释放资源 |
73
+ | 卸载 | 在 microtask 内发布 `{ visible: false }` 并释放资源;如果之前的隐藏已被接受则不再重复发送,被拒绝过则补发一次 |
93
74
 
94
- **`deps` 参数**:`ResizeObserver` 只在元素自身的 border-box 改变时触发。如果页面上有某些状态会移动元素位置却不改变其尺寸(例如兄弟节点切换、路由跳转、复杂的外部布局更新),可将这些状态放入 `deps`。
75
+ `deps`:`ResizeObserver` 只在元素自身的 border-box 改变时触发。如果页面上有某些状态会移动元素位置却不改变其尺寸(例如兄弟节点切换、路由跳转、复杂的外部布局更新),可将这些状态放入 `deps`。
95
76
 
96
- React 18 在卸载时会传入 `ref(null)`,React 19 支持 ref 清理函数。适配层将卸载通知推迟一个微任务执行:React 19 在 StrictMode 下开发阶段的快速卸载重挂载会被就地取消,真正的卸载则在微任务内正常触发清理。
77
+ 省略语义与 `createViewAnchor`、命令式 `update()` 相同:每次调用都会重新应用一份完整的选项,省略的选项一律重置为默认值,而不是沿用上一次的值。`treatZeroAreaAsHidden`、`followScroll`、`followGeometry` 省略即为 `false`;`holdSelector` 省略回到默认值 `[role="separator"]`;`dedupe` 省略回到默认值 `true`(传 `null`/`false` 才会分别关闭它们)。
97
78
 
98
- ## usePlacementAnchor(opts)
99
-
100
- 同样导出自 `view-anchor/react`,将 `createPlacementAnchor` 的 `visible`、`followScroll`、`followGeometry` 与 `guardDisplayNone` 接入 React ref 生命周期。真正卸载时会发布 `{ visible: false }` 并释放监听。
79
+ React 18 在卸载时会传入 `ref(null)`,React 19 支持 ref 清理函数。适配层将卸载通知推迟一个微任务执行:React 19 在 StrictMode 下开发阶段的快速卸载重挂载会被就地取消,真正的卸载则在微任务内正常触发清理。
101
80
 
102
81
  ## 模块结构
103
82
 
104
83
  | 文件 | 用途 |
105
84
  |---|---|
106
- | `src/view-anchor.ts` | 正向命令式核心:`createViewAnchor`、`createPlacementAnchor`、`measurePlacement`。不含 React 或宿主依赖。 |
107
- | `src/react.ts` | React 适配层:`useViewAnchor` 与 `usePlacementAnchor`。 |
108
- | `src/size-advertiser.ts` | 反向核心:`createSizeAdvertiser`。 |
109
- | `src/measure-loop.ts` | 反向专用的 RAF 调度与去重循环(内部实现,不对外导出)。 |
85
+ | `src/view-anchor.ts` | 正向命令式核心:`createViewAnchor`、`measurePlacement`。不含 React 或宿主依赖。 |
86
+ | `src/react.ts` | React 适配层:`useViewAnchor`。 |
87
+ | `src/size-anchor.ts` | 反向核心:`createSizeAnchor`,含每帧至多一次的 RAF 调度与去重。 |
110
88
  | `src/types.ts` | 类型定义(`Bounds`、`Placement`、各模块配置与句柄)。 |
111
89
  | `src/abort.ts` | 内部实现:标准 `AbortSignal` 监听与注销辅助。 |
112
- | `src/index.ts` | 根入口,导出核心几何方法并兼容性重导出 `useViewAnchor`。 |
90
+ | `src/index.ts` | 根入口,导出核心几何方法(`useViewAnchor` 只从 `view-anchor/react` 导出,根入口不依赖 React)。 |
113
91
 
114
92
  消息协议模块(`src/protocol.ts`、`src/protocol-publisher.ts` 等)详见 [通信协议设计文档](./protocol.md)。
115
93
 
@@ -1,63 +1,21 @@
1
1
  # view-anchor 性能报告
2
2
 
3
- 本报告只保留当前工作树可由 `pnpm benchmark` 直接重建的绝对数据。命令启动 3 个全新的 Node.js 进程;每个进程预热 2 次、保留 7 个样本。CPU 表的“当前中位数”是三个进程中位数的中位数,完整 JSON 含全部 21 个原始样本。
3
+ 数据由 `pnpm benchmark` 生成(3 个独立 Node.js 进程,每个预热 2 次、采样 7 次)。环境:Node.js 24.18.0、macOS arm64、Apple M4。数字只适合在同一台机器上比较,不代表浏览器布局或数据传递的耗时。
4
4
 
5
- 本次环境:Node.js 24.18.0、macOS arm64、Apple M4(10 个逻辑核心)。这些数字只适合同机比较,不是浏览器布局、序列化或数据传递的耗时承诺。
5
+ ## 结论
6
6
 
7
- ## CPU
7
+ - 与 0.2.2 相比,除协议解码外,各场景的耗时和内存都在正常波动范围内。
8
+ - 协议解码变慢,每条消息约多 30 纳秒。原因是 1.0 会检查每个对象的原型,拒绝带自定义原型的输入(见 [protocol.md](./protocol.md))。一帧只有几十条消息时,这点开销可以忽略。
9
+ - 根入口变小约四分之一。
8
10
 
9
- | 场景 | 数据量 | 当前中位数 | 三进程中位数(ms) |
10
- | --- | ---: | ---: | --- |
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 |
11
+ ## 导出体积
20
12
 
21
- ## 内存
13
+ 使用 Rolldown 打包、tree-shaking 和 minify,`react` 作为外部依赖。
22
14
 
23
- 每个状态在独立 Node.js 进程中执行,并在读数前显式 GC。表中为三个独立进程的中位数增量;heap 是 V8 保留堆,RSS 是进程常驻集合大小,二者不应混用。清理对象后,分配器也不一定立即把内存页归还给操作系统,所以 `afterClear` 的 RSS 不能当作仍有等量 JavaScript 对象存活。`dispose()` 会取消观察并释放目标元素与回调引用;这一行为由独立的 WeakRef 回归测试覆盖,不用下表的 RSS 来判断。
24
-
25
- 性能 harness 将 `queueMicrotask` 替换为 no-op,然后显式调用 `flush()`;这是刻意测量 batcher 的 pending/retained 状态,**不是**真实宿主 microtask 调度路径。
26
-
27
- | 状态(100,000 个 anchor) | heap 增量 | RSS 增量 |
28
- | --- | ---: | ---: |
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
-
35
- ## 实际导出代码体积
36
-
37
- 使用 Rolldown bundle、tree-shaking 和 minify,且将 `react` 作为 peer external。测量的是实际执行 JavaScript,不是 npm tarball、source map 或声明文件。
38
-
39
- | 完整入口 | raw | gzip | brotli |
15
+ | 入口 | raw | gzip | brotli |
40
16
  | --- | ---: | ---: | ---: |
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
-
45
- | 单独导出 | raw | gzip | brotli |
46
- | --- | ---: | ---: | ---: |
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
-
59
- ## V8 与边界
60
-
61
- 运行 `pnpm benchmark:v8 > /tmp/view-anchor-v8.log 2>&1` 时,三个新的 benchmark 进程会继承 `--trace-opt`、`--trace-deopt` 与 `--trace-turbo-inlining`。本文没有保留无对应当前 trace 工件的历史优化/反优化结论。
17
+ | `view-anchor` | 4,728B | 1,967B | 1,759B |
18
+ | `view-anchor/protocol` | 3,851B | 1,507B | 1,355B |
19
+ | `view-anchor/react` | 5,117B | 2,089B | 1,884B |
62
20
 
63
- 当前测量不覆盖真实浏览器布局、序列化、数据传递或生产工作负载;这些路径需在目标运行时另行测量。
21
+ `pnpm benchmark` 的输出包含各单独导出的体积、CPU 和内存明细。`pnpm benchmark:v8` 会额外输出 V8 的优化和反优化日志。
package/docs/protocol.md CHANGED
@@ -63,8 +63,6 @@ const publish = createPlacementMessagePublisher(
63
63
  )
64
64
  ```
65
65
 
66
- 使用 publisher 时需注意两点:
67
-
68
66
  1. **每个 `{ anchorId, generation }` 保持单个 publisher 实例**。在 React 中应使用 `useMemo` 或 `useRef` 保存,不要在每次渲染时重新创建。接收端的 `createGeometrySequenceGuard` 会按 `placement` 和 `size` 分别记录见过的最大序列号;如果在同一代次下重建 publisher,序列号会从 1 重新计数,导致新发出的消息被当成过期消息丢弃。确实需要重置时,请将 `generation` 加一。
69
67
  2. **批处理与重试**。`createGeometryBatcher` 会在当前微任务中合并同一事件循环内的多次更新,每个锚点的 `placement` 和 `size` 各自只保留最新的一条。如果下游发送失败或抛出异常,未成功发送的消息会保留在队列中,等待下次调用或显式 `flush()` 时重试。
70
68
 
@@ -103,6 +101,17 @@ if (result.ok) {
103
101
 
104
102
  所有 `Publisher<T>` 均为同步函数:
105
103
  - 返回 `true` 或 `void`:表示数据已成功接收或已加入发送队列。
106
- - 返回 `false`:表示当前未接收。核心会在下一次测量触发时重新尝试该值。
104
+ - 返回 `false`:表示当前未接收。核心会在下一次测量触发时重新尝试该值。隐藏状态不再测量,所以被拒绝的 `{ visible: false }` 不会自动重发,要等下一次 `update()`;`useViewAnchor` 会在卸载时补发。
107
105
 
108
106
  如果底层传递是异步的,应当先在同步回调中将消息放入本地队列并返回 `true`,后续的重试由发送队列自行管理。
107
+
108
+ ## 已知局限:协议不负责的事
109
+
110
+ - **不验证来源和权限**。`generation`、`seq` 只保证顺序,不是鉴权凭据;协议不检查 sender、origin、能力 token,也不判断某个 `anchorId` 是否允许某个来源写入。这些必须由接收端在调用 `guard.accept()` 之前完成(见上文“接收端”示例中的 `isAuthorized`)。
111
+ - **不是窗口级的原子快照**。一个 batch 里只包含这次微任务入队的消息;没有更新的锚点不会出现在这个 batch 里,不能把它当作整个窗口状态在某一时刻的完整快照。
112
+ - **不分配或递增 `generation`**。何时开始新的一代、`generation` 取什么值,完全由应用决定;协议只负责按 `generation` 做新旧判断。
113
+ - **只接受普通对象**。消息及其中的 `placement`、`bounds`、`size` 的原型必须是 `Object.prototype` 或 `null`,也就是 `JSON.parse` 和结构化克隆(`postMessage`)得到的对象;带自定义原型的对象一律拒绝,继承来的字段不会被当成消息内容。如果页面的全局 `Object.prototype` 已被污染,解码器无法防护。
114
+ - **关键字段没有上限**。`anchorId` 不限制长度;`createGeometrySequenceGuard` 和 `createGeometryBatcher` 内部按 `anchorId` 建立的 Map 也不限制条目数。接收不可信输入时,应用需要先鉴权,再自行限制这些资源,避免被灌入大量或超长的 `anchorId`。
115
+ - **批次上限只管条数**。`decodeGeometryWireValue` 的 `maxMessages` 只检查 `messages` 数组长度,不检查序列化后的总字节数;总体积仍需由传输层或应用自己限制。
116
+ - **不负责传输本身**。协议不做重连、确认(ack)或定时重试。发送失败的消息会留在 `GeometryBatcher` 里,直到下一次 `publish()` 产生更新的消息,或显式调用 `flush()`,才会重新尝试。
117
+ - **重建 publisher 会被当作旧消息**。同一个 `{ anchorId, generation }` 如果重新创建 `createPlacementMessagePublisher` / `createSizeMessagePublisher`,`seq` 会从 1 重新计数,很可能小于接收端已经记录的最大值而被 `guard` 丢弃。需要重置时应递增 `generation`,而不是重建同代次的 publisher。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-anchor",
3
- "version": "0.2.2",
3
+ "version": "1.0.0-beta.0",
4
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
6
  "dom",
@@ -17,9 +17,6 @@
17
17
  ],
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
- "engines": {
21
- "node": ">=24"
22
- },
23
20
  "license": "MIT",
24
21
  "author": "lbb00",
25
22
  "repository": {
@@ -83,7 +80,8 @@
83
80
  "access": "public"
84
81
  },
85
82
  "scripts": {
86
- "build": "tsc -p tsconfig.build.json && pnpm run build:docs",
83
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && pnpm run build:docs",
84
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
87
85
  "build:docs": "node scripts/build-docs.js",
88
86
  "check-types": "tsc --noEmit -p tsconfig.test.json",
89
87
  "lint": "oxlint . --deny-warnings --report-unused-disable-directives",
@@ -95,6 +93,6 @@
95
93
  "benchmark": "node --expose-gc scripts/performance-report.js",
96
94
  "benchmark:v8": "node --trace-opt --trace-deopt --trace-turbo-inlining --expose-gc scripts/performance-report.js",
97
95
  "check-package": "node scripts/check-package.js",
98
- "release": "pnpm run build && pnpm run test && pnpm run check-package && changeset publish"
96
+ "release": "pnpm run build && pnpm run check-types && pnpm run lint && pnpm run format:check && pnpm run test && pnpm run check-package && changeset publish"
99
97
  }
100
98
  }
package/src/index.ts CHANGED
@@ -2,22 +2,8 @@
2
2
  * view-anchor: keeps an external surface aligned with a DOM element's geometry.
3
3
  */
4
4
 
5
- export { createViewAnchor, measurePlacement, createPlacementAnchor } from './view-anchor.js'
6
- export type { PlacementAnchorOptions, PlacementAnchorHandle } from './view-anchor.js'
7
- export type {
8
- Bounds,
9
- Placement,
10
- Publisher,
11
- PublishResult,
12
- ViewAnchorOptions,
13
- ViewAnchorHandle,
14
- } from './types.js'
15
- export { createSizeAdvertiser } from './size-advertiser.js'
16
- export { useViewAnchor } from './react.js'
17
- export type {
18
- AdvertisedAxis,
19
- AdvertisedSize,
20
- SizeAdvertiserOptions,
21
- SizeAdvertiserHandle,
22
- } from './types.js'
23
- export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js'
5
+ export { createViewAnchor, measurePlacement } from './view-anchor.js'
6
+ export type { ViewAnchorOptions, ViewAnchorHandle } from './view-anchor.js'
7
+ export type { Bounds, Placement, Publisher, PublishResult } from './types.js'
8
+ export { createSizeAnchor } from './size-anchor.js'
9
+ export type { SizeAxis, SizeMeasurement, SizeAnchorOptions, SizeAnchorHandle } from './types.js'
@@ -1,4 +1,4 @@
1
- import type { AdvertisedSize, Placement, Publisher } from './types.js'
1
+ import type { SizeMeasurement, Placement, Publisher } from './types.js'
2
2
  import { watchAbort } from './abort.js'
3
3
  import {
4
4
  GEOMETRY_PROTOCOL_VERSION,
@@ -9,8 +9,8 @@ import {
9
9
  type SizeMessage,
10
10
  } from './protocol-types.js'
11
11
 
12
- export type GeometrySend = Publisher<GeometryMessage>
13
- export type GeometryBatchSend = Publisher<GeometryBatch>
12
+ export type GeometryMessageSender = Publisher<GeometryMessage>
13
+ export type GeometryBatchSender = Publisher<GeometryBatch>
14
14
 
15
15
  export interface GeometryBatcherOptions {
16
16
  /** Observes every batch-delivery error, including explicit flushes; it must not throw. */
@@ -41,7 +41,7 @@ export interface GeometryBatcher {
41
41
  */
42
42
  export function createPlacementMessagePublisher(
43
43
  address: GeometryAddress,
44
- send: GeometrySend,
44
+ send: GeometryMessageSender,
45
45
  ): (placement: Placement) => boolean {
46
46
  let seq = 0
47
47
 
@@ -65,8 +65,8 @@ export function createPlacementMessagePublisher(
65
65
  */
66
66
  export function createSizeMessagePublisher(
67
67
  address: GeometryAddress,
68
- send: GeometrySend,
69
- ): (size: AdvertisedSize) => boolean {
68
+ send: GeometryMessageSender,
69
+ ): (size: SizeMeasurement) => boolean {
70
70
  let seq = 0
71
71
 
72
72
  return (size) => {
@@ -84,15 +84,13 @@ export function createSizeMessagePublisher(
84
84
 
85
85
  // Terminal-state stand-in for `send` so a retained, disposed batcher does not
86
86
  // keep the caller's transport closure (and whatever it captured) alive.
87
- const NOOP_SEND: GeometryBatchSend = () => false
87
+ const NOOP_SEND: GeometryBatchSender = () => false
88
88
 
89
89
  /**
90
- * Coalesces same-task messages without adding a rendering-frame delay. It owns
91
- * no authorization policy: callers must associate addresses with trusted
92
- * sources before accepting a delivered batch.
90
+ * Coalesces same-task messages without adding a rendering-frame delay.
93
91
  */
94
92
  export function createGeometryBatcher(
95
- send: GeometryBatchSend,
93
+ send: GeometryBatchSender,
96
94
  options: GeometryBatcherOptions = {},
97
95
  ): GeometryBatcher {
98
96
  /** State is indexed by anchor so upgrades and clear(anchor) are O(1). */
@@ -203,11 +201,7 @@ export function createGeometryBatcher(
203
201
  removeAbortListener = (): void => {}
204
202
  anchors.clear()
205
203
  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.
204
+ // Drop the long-lived reference; flush() captures its own before send() runs.
211
205
  options = {}
212
206
  send = NOOP_SEND
213
207
  }
@@ -1,12 +1,9 @@
1
- import type { AdvertisedSize, Placement } from './types.js'
1
+ import type { SizeMeasurement, Placement } from './types.js'
2
2
 
3
3
  /**
4
- * Leaf module for the wire-format shapes shared by `protocol.ts` (decode/guard)
5
- * and `protocol-publisher.ts` (encode/batch). Keeping them here — rather than
6
- * in either of those two — avoids a value import cycle: `protocol.ts`
7
- * re-exports `protocol-publisher.ts`'s functions, and those functions need
8
- * `GEOMETRY_PROTOCOL_VERSION`, so neither of those two files can be the
9
- * source of it without the other importing back from it.
4
+ * Wire-format shapes shared by `protocol.ts` (decode/guard) and
5
+ * `protocol-publisher.ts` (encode/batch). Kept here to break a value
6
+ * import cycle between those two files.
10
7
  */
11
8
 
12
9
  /** Current wire format version for geometry messages. */
@@ -29,7 +26,7 @@ export interface SizeMessage extends GeometryAddress {
29
26
  v: typeof GEOMETRY_PROTOCOL_VERSION
30
27
  kind: 'size'
31
28
  seq: number
32
- size: AdvertisedSize
29
+ size: SizeMeasurement
33
30
  }
34
31
 
35
32
  export type GeometryMessage = PlacementMessage | SizeMessage
package/src/protocol.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AdvertisedSize, Placement } from './types.js'
1
+ import type { SizeMeasurement, Placement } from './types.js'
2
2
  import {
3
3
  GEOMETRY_PROTOCOL_VERSION,
4
4
  type GeometryAddress,
@@ -28,8 +28,14 @@ export interface GeometryDecodeOptions {
28
28
  maxMessages: number
29
29
  }
30
30
 
31
- const isRecord = (value: unknown): value is Record<string, unknown> =>
32
- typeof value === 'object' && value !== null && !Array.isArray(value)
31
+ // Only plain objects count, as produced by JSON.parse or structured clone. A
32
+ // custom prototype cannot supply missing fields; checking the prototype once
33
+ // per object is much cheaper than an own-property check per field.
34
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
35
+ if (typeof value !== 'object' || value === null) return false
36
+ const proto = Object.getPrototypeOf(value)
37
+ return proto === Object.prototype || proto === null
38
+ }
33
39
 
34
40
  const isSafeInteger = (value: unknown): value is number =>
35
41
  typeof value === 'number' && Number.isSafeInteger(value)
@@ -57,7 +63,7 @@ function decodePlacement(value: unknown): Placement | undefined {
57
63
  return { visible: true, bounds: { x, y, width, height } }
58
64
  }
59
65
 
60
- function decodeSize(value: unknown): AdvertisedSize | undefined {
66
+ function decodeSize(value: unknown): SizeMeasurement | undefined {
61
67
  if (!isRecord(value)) return undefined
62
68
  if (value.axis !== 'block' && value.axis !== 'inline') return undefined
63
69
  if (!isNonNegativeSafeInteger(value.extent)) return undefined
@@ -89,7 +95,7 @@ function decodeMessage(value: unknown): GeometryMessage | undefined {
89
95
 
90
96
  /**
91
97
  * Decodes untrusted transport data without throwing. `maxMessages` is required
92
- * so each receiver, rather than this library, chooses its own batch limit.
98
+ * so each receiver chooses its own batch limit.
93
99
  */
94
100
  export function decodeGeometryWireValue(
95
101
  value: unknown,
@@ -176,6 +182,6 @@ export {
176
182
  export type {
177
183
  GeometryBatcher,
178
184
  GeometryBatcherOptions,
179
- GeometryBatchSend,
180
- GeometrySend,
185
+ GeometryBatchSender,
186
+ GeometryMessageSender,
181
187
  } from './protocol-publisher.js'