Skip to content

Commit a05e803

Browse files
committed
refactor(0): extract createObserver factory from observer composables
Eliminates ~200 lines of copy-paste across useResizeObserver, useIntersectionObserver, and useMutationObserver by factoring the shared lifecycle — state setup, hydration watch, target watch, pause/resume/stop, onScopeDispose — into a single createObserver factory. Each observer is now ~30 lines of config. Also fixes several issues surfaced by a 5-agent swarm inspection: - shallowRef<O | null | undefined>(undefined) corrects the three-state type - ObservableNodeList replaces `as unknown as NodeList` cast in mutation immediate - UseElementSizeReturn.width/height are now Readonly<Ref<number>> - UseXObserverReturn interfaces extend ObserverReturn (single source of truth) - Three-state sentinel documented in module docblock (no inline comment) All behavioral invariants preserved. 70 test files, 2818 tests pass.
1 parent 999a818 commit a05e803

File tree

4 files changed

+287
-431
lines changed

4 files changed

+287
-431
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* @module createObserver
3+
*
4+
* @remarks
5+
* Internal factory for browser Observer API composables. Encapsulates the
6+
* shared lifecycle, hydration, pause/resume/stop, and target-watch logic
7+
* used by useResizeObserver, useIntersectionObserver, and useMutationObserver.
8+
*
9+
* The `observer` ref uses a three-state sentinel:
10+
* - `undefined` — not yet created
11+
* - `null` — permanently stopped (`stop()` was called; `setup()` is a no-op)
12+
* - instance — active observer
13+
*/
14+
15+
// Composables
16+
import { useHydration } from '#v0/composables/useHydration'
17+
18+
// Utilities
19+
import { isNull } from '#v0/utilities'
20+
import { onScopeDispose, shallowReadonly, shallowRef, toRef, watch } from 'vue'
21+
22+
// Types
23+
import type { MaybeRef, Ref } from 'vue'
24+
25+
export interface ObserverReturn {
26+
/**
27+
* Whether the observer is currently active (created and observing)
28+
*/
29+
readonly isActive: Readonly<Ref<boolean>>
30+
/**
31+
* Whether the observer is currently paused
32+
*/
33+
readonly isPaused: Readonly<Ref<boolean>>
34+
/**
35+
* Pause observation (disconnects observer but keeps it alive)
36+
*/
37+
pause: () => void
38+
/**
39+
* Resume observation
40+
*/
41+
resume: () => void
42+
/**
43+
* Stop observation and clean up (destroys observer)
44+
*/
45+
stop: () => void
46+
}
47+
48+
interface ObserverConfig<O extends { disconnect: () => void }, E> {
49+
supports: boolean
50+
once?: boolean
51+
create: (callback: (entries: E[]) => void) => O
52+
observe: (observer: O, el: Element) => void
53+
shouldStop?: (entries: E[]) => boolean
54+
onEntry?: (entries: E[]) => void
55+
onPause?: () => void
56+
immediate?: (el: Element) => E[]
57+
onceIncludesImmediate?: boolean
58+
}
59+
60+
export function createObserver<O extends { disconnect: () => void }, E> (
61+
target: MaybeRef<Element | null | undefined>,
62+
userCallback: (entries: E[]) => void,
63+
config: ObserverConfig<O, E>,
64+
): ObserverReturn {
65+
const { isHydrated } = useHydration()
66+
const targetRef = toRef(target)
67+
const observer = shallowRef<O | null | undefined>(undefined)
68+
const isPaused = shallowRef(false)
69+
const isActive = toRef(() => !!observer.value)
70+
71+
function invoke (entries: E[]) {
72+
config.onEntry?.(entries)
73+
userCallback(entries)
74+
75+
if (config.once) {
76+
const shouldStop = config.shouldStop ? config.shouldStop(entries) : true
77+
if (shouldStop) stop()
78+
}
79+
}
80+
81+
function setup () {
82+
if (isNull(observer.value)) return
83+
if (!isHydrated.value || !config.supports || !targetRef.value || isPaused.value) return
84+
85+
observer.value = config.create(invoke)
86+
config.observe(observer.value, targetRef.value)
87+
88+
if (config.immediate) {
89+
const entries = config.immediate(targetRef.value)
90+
if (config.onceIncludesImmediate) {
91+
invoke(entries)
92+
} else {
93+
config.onEntry?.(entries)
94+
userCallback(entries)
95+
}
96+
}
97+
}
98+
99+
watch(
100+
() => targetRef.value,
101+
(el, oldEl) => {
102+
if (oldEl || observer.value) cleanup()
103+
if (isHydrated.value && el) setup()
104+
},
105+
{ immediate: true },
106+
)
107+
108+
let stopHydrationWatch: (() => void) | undefined
109+
if (!isHydrated.value) {
110+
stopHydrationWatch = watch(
111+
() => isHydrated.value,
112+
hydrated => {
113+
if (hydrated && targetRef.value && !observer.value) {
114+
setup()
115+
stopHydrationWatch?.()
116+
stopHydrationWatch = undefined
117+
}
118+
},
119+
)
120+
}
121+
122+
function cleanup () {
123+
if (observer.value) {
124+
observer.value.disconnect()
125+
observer.value = undefined
126+
}
127+
}
128+
129+
function pause () {
130+
isPaused.value = true
131+
config.onPause?.()
132+
observer.value?.disconnect()
133+
}
134+
135+
function resume () {
136+
isPaused.value = false
137+
setup()
138+
}
139+
140+
function stop () {
141+
stopHydrationWatch?.()
142+
stopHydrationWatch = undefined
143+
cleanup()
144+
observer.value = null
145+
}
146+
147+
onScopeDispose(stop, true)
148+
149+
return {
150+
isActive: shallowReadonly(isActive),
151+
isPaused: shallowReadonly(isPaused),
152+
pause,
153+
resume,
154+
stop,
155+
}
156+
}

packages/0/src/composables/useIntersectionObserver/index.ts

Lines changed: 45 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
import { SUPPORTS_INTERSECTION_OBSERVER } from '#v0/constants/globals'
2020

2121
// Composables
22-
import { useHydration } from '#v0/composables/useHydration'
22+
import { createObserver } from '#v0/composables/createObserver'
2323

2424
// Utilities
25-
import { isNull } from '#v0/utilities'
26-
import { onScopeDispose, shallowReadonly, shallowRef, toRef, watch } from 'vue'
25+
import { shallowReadonly, shallowRef } from 'vue'
2726

2827
// Types
28+
import type { ObserverReturn } from '#v0/composables/createObserver'
2929
import type { Ref, MaybeRef } from 'vue'
3030

3131
export interface IntersectionObserverEntry {
@@ -46,36 +46,11 @@ export interface IntersectionObserverOptions {
4646
threshold?: number | number[]
4747
}
4848

49-
export interface UseIntersectionObserverReturn {
50-
/**
51-
* Whether the observer is currently active (created and observing)
52-
*/
53-
readonly isActive: Readonly<Ref<boolean>>
54-
49+
export interface UseIntersectionObserverReturn extends ObserverReturn {
5550
/**
5651
* Whether the target element is currently intersecting with the viewport
5752
*/
5853
readonly isIntersecting: Readonly<Ref<boolean>>
59-
60-
/**
61-
* Whether the observer is currently paused
62-
*/
63-
readonly isPaused: Readonly<Ref<boolean>>
64-
65-
/**
66-
* Pause observation (disconnects observer but keeps it alive)
67-
*/
68-
pause: () => void
69-
70-
/**
71-
* Resume observation
72-
*/
73-
resume: () => void
74-
75-
/**
76-
* Stop observation and clean up (destroys observer)
77-
*/
78-
stop: () => void
7954
}
8055

8156
/**
@@ -122,124 +97,54 @@ export function useIntersectionObserver (
12297
callback: (entries: IntersectionObserverEntry[]) => void,
12398
options: IntersectionObserverOptions = {},
12499
): UseIntersectionObserverReturn {
125-
const { isHydrated } = useHydration()
126-
const targetRef = toRef(target)
127-
const observer = shallowRef<IntersectionObserver | null>()
128-
const isPaused = shallowRef(false)
129100
const isIntersecting = shallowRef(false)
130-
const isActive = toRef(() => !!observer.value)
131-
132-
function setup () {
133-
// null = permanently stopped, undefined = not yet created
134-
if (isNull(observer.value)) return
135-
if (!isHydrated.value || !SUPPORTS_INTERSECTION_OBSERVER || !targetRef.value || isPaused.value) return
136-
137-
observer.value = new IntersectionObserver(entries => {
138-
const transformedEntries: IntersectionObserverEntry[] = entries.map(entry => ({
139-
boundingClientRect: entry.boundingClientRect,
140-
intersectionRatio: entry.intersectionRatio,
141-
intersectionRect: entry.intersectionRect,
142-
isIntersecting: entry.isIntersecting,
143-
rootBounds: entry.rootBounds,
144-
target: entry.target,
145-
time: entry.time,
146-
}))
147-
148-
const latestEntry = transformedEntries.at(-1)
149-
if (latestEntry) isIntersecting.value = latestEntry.isIntersecting
150101

151-
callback(transformedEntries)
152-
153-
if (options.once && latestEntry?.isIntersecting) {
154-
stop()
155-
}
102+
const base = createObserver(target, callback, {
103+
supports: SUPPORTS_INTERSECTION_OBSERVER,
104+
once: options.once,
105+
create: cb => new IntersectionObserver(entries => {
106+
cb(entries.map(e => ({
107+
boundingClientRect: e.boundingClientRect,
108+
intersectionRatio: e.intersectionRatio,
109+
intersectionRect: e.intersectionRect,
110+
isIntersecting: e.isIntersecting,
111+
rootBounds: e.rootBounds,
112+
target: e.target,
113+
time: e.time,
114+
})))
156115
}, {
157-
root: options.root || null,
158-
rootMargin: options.rootMargin || '0px',
159-
threshold: options.threshold || 0,
160-
})
161-
162-
observer.value.observe(targetRef.value)
163-
164-
if (options.immediate) {
165-
const rect = targetRef.value.getBoundingClientRect()
166-
const syntheticEntry: IntersectionObserverEntry = {
167-
boundingClientRect: rect,
168-
intersectionRatio: 0,
169-
intersectionRect: new DOMRect(0, 0, 0, 0),
170-
isIntersecting: false,
171-
rootBounds: null,
172-
target: targetRef.value,
173-
time: performance.now(),
174-
}
175-
176-
callback([syntheticEntry])
177-
}
178-
}
179-
180-
// Watch target changes - cleanup when element changes or is removed
181-
watch(
182-
() => targetRef.value,
183-
(el, oldEl) => {
184-
// Cleanup if we had a previous element or if observer exists (handles paused state)
185-
if (oldEl || observer.value) cleanup()
186-
187-
if (isHydrated.value && el) {
188-
setup()
189-
}
116+
root: options.root ?? null,
117+
rootMargin: options.rootMargin ?? '0px',
118+
threshold: options.threshold ?? 0,
119+
}),
120+
observe: (obs, el) => obs.observe(el),
121+
onEntry: entries => {
122+
const latest = entries.at(-1)
123+
if (latest) isIntersecting.value = latest.isIntersecting
190124
},
191-
{ immediate: true },
192-
)
193-
194-
// Handle initial hydration - setup once when hydrated if target exists
195-
let stopHydrationWatch: (() => void) | undefined
196-
if (!isHydrated.value) {
197-
stopHydrationWatch = watch(
198-
() => isHydrated.value,
199-
hydrated => {
200-
if (hydrated && targetRef.value && !observer.value) {
201-
setup()
202-
stopHydrationWatch?.()
203-
stopHydrationWatch = undefined
204-
}
205-
},
206-
)
207-
}
208-
209-
function cleanup () {
210-
if (observer.value) {
211-
observer.value.disconnect()
212-
observer.value = undefined
213-
}
214-
}
215-
216-
function pause () {
217-
isPaused.value = true
218-
isIntersecting.value = false
219-
observer.value?.disconnect()
220-
}
221-
222-
function resume () {
223-
isPaused.value = false
224-
setup()
225-
}
226-
227-
function stop () {
228-
stopHydrationWatch?.()
229-
stopHydrationWatch = undefined
230-
cleanup()
231-
observer.value = null
232-
}
233-
234-
onScopeDispose(stop, true)
125+
onPause: () => {
126+
isIntersecting.value = false
127+
},
128+
shouldStop: entries => entries.at(-1)?.isIntersecting ?? false,
129+
immediate: options.immediate
130+
? el => {
131+
const rect = el.getBoundingClientRect()
132+
return [{
133+
boundingClientRect: rect,
134+
intersectionRatio: 0,
135+
intersectionRect: new DOMRect(0, 0, 0, 0),
136+
isIntersecting: false,
137+
rootBounds: null,
138+
target: el,
139+
time: performance.now(),
140+
}]
141+
}
142+
: undefined,
143+
})
235144

236145
return {
237-
isActive: shallowReadonly(isActive),
146+
...base,
238147
isIntersecting: shallowReadonly(isIntersecting),
239-
isPaused: shallowReadonly(isPaused),
240-
pause,
241-
resume,
242-
stop,
243148
}
244149
}
245150

0 commit comments

Comments
 (0)