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/src/react.ts CHANGED
@@ -1,11 +1,6 @@
1
- import { useCallback, useEffect, useRef } from 'react'
2
- import {
3
- createPlacementAnchor,
4
- createViewAnchor,
5
- type PlacementAnchorHandle,
6
- type PlacementAnchorOptions,
7
- } from './view-anchor.js'
8
- import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
1
+ import { useCallback, useEffect, useInsertionEffect, useRef, version } from 'react'
2
+ import { createViewAnchor, type ViewAnchorHandle } from './view-anchor.js'
3
+ import type { ViewAnchorOptions } from './view-anchor.js'
9
4
 
10
5
  export interface UseViewAnchorOptions extends ViewAnchorOptions {
11
6
  /**
@@ -18,24 +13,13 @@ export interface UseViewAnchorOptions extends ViewAnchorOptions {
18
13
  /** Compatible with React 18's null callback and React 19's ref cleanup. */
19
14
  export type ViewAnchorRef = (el: HTMLElement | null) => void | (() => void)
20
15
 
21
- export interface UsePlacementAnchorOptions extends PlacementAnchorOptions {
22
- /**
23
- * Values that re-apply the anchor when changed. Keep this array's length
24
- * stable across renders.
25
- */
26
- deps?: ReadonlyArray<unknown>
27
- }
28
-
29
- /** Callback ref for the explicit-visibility Placement API. */
30
- export type PlacementAnchorRef = ViewAnchorRef
31
-
32
16
  type AnchorHandle = { dispose(): void }
33
17
 
34
18
  interface LifecycleAdapter<Options, Handle extends AnchorHandle> {
35
19
  create(target: HTMLElement, options: Options): Handle
36
20
  update(handle: Handle, options: Options): void
37
21
  collapse(handle: Handle, options: Options): void
38
- isCollapsed(options: Options): boolean
22
+ isCollapsed(handle: Handle, options: Options): boolean
39
23
  }
40
24
 
41
25
  // Callback refs own the imperative anchor because React invokes them during commit.
@@ -50,12 +34,16 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
50
34
  const handleRef = useRef<Handle | null>(null)
51
35
  const elementRef = useRef<HTMLElement | null>(null)
52
36
  const optionsRef = useRef(options)
53
- optionsRef.current = options
54
37
  const adapterRef = useRef(adapter)
55
- adapterRef.current = adapter
56
38
  const appliedRef = useRef(applied)
57
39
  const currentAppliedRef = useRef(applied)
58
- currentAppliedRef.current = applied
40
+ // Renders discarded before commit (e.g. suspended transitions) must not
41
+ // update optionsRef. Insertion effects run before callback refs in the same commit.
42
+ useInsertionEffect(() => {
43
+ optionsRef.current = options
44
+ adapterRef.current = adapter
45
+ currentAppliedRef.current = applied
46
+ })
59
47
  // Options handed to the adapter on the last create/update call.
60
48
  // Tracks applied state across renders where the deps array reference changes.
61
49
  const lastAppliedOptionsRef = useRef(options)
@@ -69,7 +57,7 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
69
57
  const handle = handleRef.current
70
58
  if (!handle) return
71
59
  const adapter = adapterRef.current
72
- const alreadyCollapsed = adapter.isCollapsed(lastAppliedOptionsRef.current)
60
+ const alreadyCollapsed = adapter.isCollapsed(handle, lastAppliedOptionsRef.current)
73
61
  try {
74
62
  if (!alreadyCollapsed) adapter.collapse(handle, optionsRef.current)
75
63
  } finally {
@@ -87,10 +75,15 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
87
75
  })
88
76
  }
89
77
 
78
+ // React 19 invokes the cleanup returned from a callback ref instead of calling
79
+ // ref(null). React 18 warns about the returned function and still calls ref(null).
80
+ const detachCleanup = (element: HTMLElement): (() => void) | undefined =>
81
+ Number.parseInt(version, 10) >= 19 ? () => deferDetach(element) : undefined
82
+
90
83
  const ref = useCallback<ViewAnchorRef>((element) => {
91
84
  if (element === elementRef.current) {
92
85
  cancelPendingDetach()
93
- return element ? () => deferDetach(element) : undefined
86
+ return element ? detachCleanup(element) : undefined
94
87
  }
95
88
 
96
89
  cancelPendingDetach()
@@ -110,7 +103,7 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
110
103
  handleRef.current = adapterRef.current.create(element, optionsRef.current)
111
104
  appliedRef.current = currentAppliedRef.current
112
105
  lastAppliedOptionsRef.current = optionsRef.current
113
- return () => deferDetach(element)
106
+ return detachCleanup(element)
114
107
  }
115
108
  return undefined
116
109
  }, [])
@@ -141,62 +134,56 @@ function useAnchorRef<Options, Handle extends AnchorHandle>(
141
134
  return ref
142
135
  }
143
136
 
144
- const viewAdapter: LifecycleAdapter<ViewAnchorOptions, ViewAnchorHandle> = {
145
- create: createViewAnchor,
146
- update(handle, options) {
147
- handle.update(options)
148
- },
149
- collapse(handle, options) {
150
- handle.update({ present: false, publish: options.publish })
151
- },
152
- isCollapsed(options) {
153
- return !options.present
137
+ // Whether each handle's latest { visible: false } was accepted. The core does
138
+ // not retry a rejected hidden placement, so unmount must send it again.
139
+ type HideState = { accepted: boolean }
140
+ const hideStates = new WeakMap<ViewAnchorHandle, HideState>()
141
+
142
+ const trackHide = (options: ViewAnchorOptions, state: HideState): ViewAnchorOptions => ({
143
+ ...options,
144
+ publish(placement) {
145
+ if (placement.visible) return options.publish(placement)
146
+ state.accepted = false
147
+ const result = options.publish(placement)
148
+ state.accepted = result !== false
149
+ return result
154
150
  },
155
- }
156
-
157
- /** Bind zero-bounds visibility to a DOM element callback ref. */
158
- export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
159
- return useAnchorRef(
160
- options,
161
- [options.present, options.publish, ...(options.deps ?? [])],
162
- viewAdapter,
163
- )
164
- }
151
+ })
165
152
 
166
- const placementAdapter: LifecycleAdapter<PlacementAnchorOptions, PlacementAnchorHandle> = {
167
- create: createPlacementAnchor,
153
+ const viewAdapter: LifecycleAdapter<ViewAnchorOptions, ViewAnchorHandle> = {
154
+ create(target, options) {
155
+ const state: HideState = { accepted: false }
156
+ const handle = createViewAnchor(target, trackHide(options, state))
157
+ hideStates.set(handle, state)
158
+ return handle
159
+ },
168
160
  update(handle, options) {
169
- // In React, an omitted option represents "off" for that render,
170
- // rather than keeping the previous value.
171
- handle.update({
172
- ...options,
173
- guardDisplayNone: options.guardDisplayNone ?? false,
174
- followScroll: options.followScroll ?? false,
175
- followGeometry: options.followGeometry ?? false,
176
- })
161
+ // Options are forwarded as-is; the hook applies the same "omitted resets
162
+ // to default" rule as the core's update().
163
+ handle.update(trackHide(options, hideStates.get(handle)!))
177
164
  },
178
165
  collapse(handle, options) {
179
- handle.update({ ...options, visible: false })
166
+ handle.update(trackHide({ ...options, visible: false }, hideStates.get(handle)!))
180
167
  },
181
- isCollapsed(options) {
182
- return !options.visible
168
+ isCollapsed(handle, options) {
169
+ return !options.visible && hideStates.get(handle)!.accepted
183
170
  },
184
171
  }
185
172
 
186
173
  /** Bind the explicit Placement API to a DOM element callback ref. */
187
- export function usePlacementAnchor(options: UsePlacementAnchorOptions): PlacementAnchorRef {
174
+ export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
188
175
  return useAnchorRef(
189
176
  options,
190
177
  [
191
178
  options.visible,
192
179
  options.publish,
193
- options.guardDisplayNone,
180
+ options.treatZeroAreaAsHidden,
194
181
  options.followScroll,
195
182
  options.followGeometry,
183
+ options.holdSelector,
184
+ options.dedupe,
196
185
  ...(options.deps ?? []),
197
186
  ],
198
- placementAdapter,
187
+ viewAdapter,
199
188
  )
200
189
  }
201
-
202
- export type { Bounds }
@@ -0,0 +1,131 @@
1
+ import type { SizeAnchorOptions, SizeAnchorHandle } from './types.js'
2
+ import { watchAbort } from './abort.js'
3
+
4
+ // Replaces a disposed instance's publish callback so a retained handle does
5
+ // not keep the caller's original callback (and whatever it captured) alive.
6
+ const NOOP_PUBLISH = (): false => false
7
+
8
+ /**
9
+ * Report content size for a single axis back to the host.
10
+ *
11
+ * Runs in a downstream document, reads the content size from
12
+ * `ResizeObserverEntry.borderBoxSize` without triggering reflow, and
13
+ * publishes at most once per animation frame, skipping unchanged extents by
14
+ * default (see the `dedupe` option).
15
+ *
16
+ * Measurements are rounded to integer pixels and clamped to >= 0.
17
+ *
18
+ * `target` must be a shrink-to-fit wrapper on the owned axis.
19
+ * If its size is driven by the host view itself (such as `<body>` or `<html>`),
20
+ * updates will not shrink back to content size.
21
+ */
22
+ export function createSizeAnchor(target: HTMLElement, opts: SizeAnchorOptions): SizeAnchorHandle {
23
+ const axis = opts.axis
24
+ let dedupe = opts.dedupe ?? true
25
+ let publish = opts.publish
26
+ let observer: ResizeObserver | null = null
27
+ let rafId: number | null = null
28
+ let disposed = false
29
+ // Latest border-box recorded by the ResizeObserver callback.
30
+ let latestBoxSize: ResizeObserverSize | null = null
31
+ // Last extent passed to publish; null until one is accepted.
32
+ let lastPublished: number | null = null
33
+ let publicationRevision = 0
34
+
35
+ const measure = (): number | null => {
36
+ if (!latestBoxSize) return null
37
+ const raw = axis === 'block' ? latestBoxSize.blockSize : latestBoxSize.inlineSize
38
+ if (!Number.isFinite(raw)) return null
39
+ return Math.max(0, Math.round(raw))
40
+ }
41
+
42
+ const publishExtent = (extent: number): void => {
43
+ const previous = lastPublished
44
+ const attempt = ++publicationRevision
45
+ lastPublished = extent
46
+ try {
47
+ const accepted = publish({ axis, extent }) !== false
48
+ // A reentrant update() or dispose() during publish() already moved the
49
+ // baseline; do not roll back over that newer state.
50
+ if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
51
+ } catch (error) {
52
+ if (publicationRevision === attempt && !disposed) lastPublished = previous
53
+ throw error
54
+ }
55
+ }
56
+
57
+ const publishFrame = (): void => {
58
+ rafId = null
59
+ if (disposed) return
60
+ const extent = measure()
61
+ if (extent === null) return
62
+ if (dedupe && extent === lastPublished) return
63
+ publishExtent(extent)
64
+ }
65
+
66
+ const onResize: ResizeObserverCallback = (entries) => {
67
+ // A callback queued before disconnect() can still fire once more; do not
68
+ // let it write `latestBoxSize` after dispose() has already cleared it.
69
+ if (disposed) return
70
+ const entry = entries[entries.length - 1]
71
+ if (entry) {
72
+ latestBoxSize = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latestBoxSize
73
+ }
74
+ if (rafId === null) rafId = requestAnimationFrame(publishFrame)
75
+ }
76
+
77
+ // Warn if measuring body or documentElement, whose size matches the view.
78
+ const doc = target.ownerDocument
79
+ if (target === doc.body || target === doc.documentElement) {
80
+ console.warn(
81
+ `[view-anchor] size-anchor: <${target === doc.body ? 'body' : 'html'}>'s ` +
82
+ `${axis} size is the view size, not content size. The size anchor will ` +
83
+ `never shrink to content; measure a shrink-to-fit wrapper instead.`,
84
+ )
85
+ }
86
+
87
+ let removeAbortListener = (): void => {}
88
+
89
+ const dispose = (): void => {
90
+ if (disposed) return
91
+ disposed = true
92
+ removeAbortListener()
93
+ removeAbortListener = (): void => {}
94
+ if (rafId !== null) {
95
+ cancelAnimationFrame(rafId)
96
+ rafId = null
97
+ }
98
+ if (observer) {
99
+ observer.disconnect()
100
+ observer = null
101
+ }
102
+ publish = NOOP_PUBLISH
103
+ latestBoxSize = null
104
+ lastPublished = null
105
+ }
106
+
107
+ if (opts.signal?.aborted) dispose()
108
+ else {
109
+ removeAbortListener = watchAbort(opts.signal, dispose)
110
+ observer = new ResizeObserver(onResize)
111
+ observer.observe(target)
112
+ }
113
+
114
+ return {
115
+ update(next: Omit<SizeAnchorOptions, 'signal' | 'axis'>): void {
116
+ if (disposed) return
117
+ publish = next.publish
118
+ dedupe = next.dedupe ?? true
119
+ // The new callback has not accepted anything yet, so an extent the old
120
+ // callback rejected (or never saw) must not stay latched as the dedupe
121
+ // baseline — otherwise a later identical tick would be silently
122
+ // deduped against a value this callback was never actually given.
123
+ lastPublished = null
124
+ // Re-publish the current size to the new sink immediately so it is not
125
+ // empty until the next ResizeObserver tick.
126
+ const extent = measure()
127
+ if (extent !== null) publishExtent(extent)
128
+ },
129
+ dispose,
130
+ }
131
+ }
package/src/types.ts CHANGED
@@ -27,54 +27,46 @@ export type Publisher<T> = (value: T) => PublishResult
27
27
  */
28
28
  export type Placement = { visible: true; bounds: Bounds } | { visible: false }
29
29
 
30
- export interface ViewAnchorOptions {
31
- /**
32
- * Whether the native view should be attached. When false, publishes
33
- * zero bounds ({ x: 0, y: 0, width: 0, height: 0 }) so the host can detach
34
- * the view while keeping its instance alive.
35
- */
36
- present: boolean
37
- /** Receives the live rect, or zero bounds when detached. */
38
- publish: Publisher<Bounds>
39
- /** Stops this anchor when aborted. An already-aborted signal starts no work. */
40
- signal?: AbortSignal
41
- }
42
-
43
- export interface ViewAnchorHandle {
44
- /** Apply new options and re-publish immediately. */
45
- update(opts: ViewAnchorOptions): void
46
- /** Stop observing and clean up listeners. After disposal no further values are published. */
47
- dispose(): void
48
- }
49
-
50
- // --- Reverse direction: size advertiser ---
30
+ // --- Reverse direction: size anchor ---
51
31
  //
52
32
  // Runs in a downstream document to report content size back to the host,
53
33
  // allowing the host's DOM placeholder to match the content.
54
34
 
55
- /** Which axis this advertiser reports: 'block' (height) or 'inline' (width). */
56
- export type AdvertisedAxis = 'block' | 'inline'
35
+ /** Which axis this size anchor reports: 'block' (height) or 'inline' (width). */
36
+ export type SizeAxis = 'block' | 'inline'
57
37
 
58
- /** One frame of advertised size on the owned axis. */
59
- export interface AdvertisedSize {
60
- /** The axis this advertiser reports ('block' or 'inline'). */
61
- readonly axis: AdvertisedAxis
62
- /** The content extent in CSS pixels, rounded and non-negative. */
38
+ /** One measured size on the owned axis. */
39
+ export interface SizeMeasurement {
40
+ /** The axis this size anchor reports ('block' or 'inline'). */
41
+ readonly axis: SizeAxis
42
+ /**
43
+ * The target's border-box size on this axis in CSS pixels, rounded and
44
+ * non-negative. Falls back to the content box where no border box is reported.
45
+ */
63
46
  readonly extent: number
64
47
  }
65
48
 
66
- export interface SizeAdvertiserOptions {
67
- /** The single axis this advertiser owns. Fixed for the advertiser's lifetime. */
68
- axis: AdvertisedAxis
69
- /** Receives each advertised size. */
70
- publish: Publisher<AdvertisedSize>
71
- /** Stops this advertiser when aborted. An already-aborted signal starts no work. */
49
+ export interface SizeAnchorOptions {
50
+ /** The single axis this size anchor owns. Fixed for its lifetime. */
51
+ axis: SizeAxis
52
+ /** Receives each measured size. */
53
+ publish: Publisher<SizeMeasurement>
54
+ /** Stops this size anchor when aborted. An already-aborted signal starts no work. */
72
55
  signal?: AbortSignal
56
+ /**
57
+ * When true (the default), a measurement identical to the last published
58
+ * extent is not published again. When false, every measurement publishes,
59
+ * even an unchanged extent. Omitting it in update() resets to true.
60
+ */
61
+ dedupe?: boolean
73
62
  }
74
63
 
75
- export interface SizeAdvertiserHandle {
76
- /** Swap the publish callback and re-advertise the current size immediately. */
77
- update(publish: Publisher<AdvertisedSize>): void
64
+ export interface SizeAnchorHandle {
65
+ /**
66
+ * Apply a full set of options and re-publish the current size
67
+ * immediately. Like creation, an omitted dedupe resets to true.
68
+ */
69
+ update(opts: Omit<SizeAnchorOptions, 'signal' | 'axis'>): void
78
70
  /** Stop observing and cancel any pending animation frame. */
79
71
  dispose(): void
80
72
  }