Native IntersectionObserver and ResizeObserver are cheap to create and
expensive to have hundreds of. Every component that wants to fade in on scroll,
lazy-load an image, or react to a resize would otherwise mint its own observer,
and each one is a fresh source of layout reads. The theme wraps both in
singletons that multiplex — one real observer shared by every consumer — so
you get resource conservation and a single, coordinated reflow source instead
of N competing ones.
The API is the same shape for both: construct with an element and a callback,
then call observe().
Intersection — one observer per options signature
GlobalIntersectionObserver keeps a static registry keyed by the options
signature. Consumers that ask for the same threshold/rootMargin share one
underlying IntersectionObserver; a different signature lazily mints a new
group. Within a group, an element → Set<consumer> registry lets many consumers
watch the same element, and each entry fans out to every consumer registered for
its target.
// components/Animation/Animation.js
this.observer = new GlobalIntersectionObserver(this.element, this.animateIn.bind(this))
// …later, once ready:
this.observer.observe()
Pass options as the third argument to join (or create) the matching group. The
default is a 0.33 threshold:
// modules/LocationMap/LocationMap.js — a 300px pre-load margin
;(new GlobalIntersectionObserver(this.element, () => {
// boot the map just before it scrolls into view
}, { rootMargin: '300px 0px' })).observe()
Intersection is one-shot: when an element intersects, the observer unobserves it before firing your callback, so a fade-in or a lazy-load runs exactly once — you don't have to disconnect anything yourself. That makes it the right tool for lazy-loading an image the moment it nears the viewport:
// components/Image/Image.js
;(new GlobalIntersectionObserver(this.element, () => {
// swap in the real source
})).observe()
Resize — one observer, with delta tracking
GlobalResizeObserver shares a single ResizeObserver across every consumer via
the same element → Set<consumer> registry. Each consumer tracks its own
previous width and height, so its callback receives not just the current size but
the delta since the last call — letting you react only to the axis that
actually changed:
// components/Masonry/Masonry.js
this.#_observer = new GlobalResizeObserver(this.element, ({ inline }) => {
if ( inline !== false ) {
this.refresh() // only relayout when the width moved
}
})
The callback is invoked with { inline, block, width, height } — where inline
and block are the signed deltas (or false when that axis didn't move). Two
details worth knowing:
- Callbacks run on a background task. Every fan-out is scheduled through
backgroundTask, so resize reactions never pile onto the frame that fired them. See Scheduling. - One initial measurement is forced. The constructor schedules a first
#call()so a consumer that registers late still gets its starting dimensions.
It also attaches a single debounced window.resize listener that re-fires every
consumer when the viewport height changes — the fix for mobile URL-bar
resizes that a ResizeObserver alone misses.
Why singletons. The point isn't only fewer objects. A ResizeObserver
callback is a layout read; funnelling every consumer through one observer and
one background task makes the whole page's resize handling a single,
schedulable reflow source instead of a scattered pile of them. The registry is
a Set per element precisely so adding an Nth consumer costs nothing at the
browser level.
Related
- Scheduling —
backgroundTask, which every Resize callback is deferred through. - Sub-components &
HasX— the hosts that construct observers as they wire behaviour.