@abide/abide 0.45.0 → 0.46.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # abide
2
2
 
3
+ ## 0.46.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 162296f: wake a below-the-fold deferred {#await cache()} on visible, not idle ([`193ff2f`](https://github.com/briancray/abide/commit/193ff2f7ad4a40f3dde39537260d76e7317a6013))
8
+ - 162296f: client:idle / client:visible island directive on components ([`a50f0bc`](https://github.com/briancray/abide/commit/a50f0bce451f00f292eea90419feaa6ff1a708bd))
9
+ - 162296f: wake deferred {#await cache()} on idle + size-gate the defer decision ([`a5a1a7f`](https://github.com/briancray/abide/commit/a5a1a7fea3dfe35cad08ea17fad7016fb8cca6cb))
10
+ - 162296f: component-island hydration boundary (mountRange) + shared wake helpers ([`e0ed9ba`](https://github.com/briancray/abide/commit/e0ed9ba58d41ad7d1605717b1f4ec9207d2454a5))
11
+
12
+ ### Patch Changes
13
+
14
+ - 162296f: rename shadowed `constructor` local in whenVisible ([`2f25fdf`](https://github.com/briancray/abide/commit/2f25fdfcb21e05807dd26c876acd8cd9f49d3004))
15
+ - 162296f: shared observer + frame-budgeted wake flush for islands at scale ([`3c2db3b`](https://github.com/briancray/abide/commit/3c2db3bce43ad8cc30b6cab279246a033db4bd61))
16
+ - 162296f: apply formatter to drifted files ([`5960d3c`](https://github.com/briancray/abide/commit/5960d3c5db44c925cf3c1fc9566156a8886db195))
17
+ - 162296f: restore multi-line union wrapping in generateBuild ([`92ce908`](https://github.com/briancray/abide/commit/92ce908c3369759d8b55c695ed2deed410be1ef5))
18
+
3
19
  ## 0.45.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -161,7 +161,8 @@ export function defineRpc<Args, Return>(
161
161
  /* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
162
162
  function abortRpcTimeout(): void {
163
163
  const req = requestContext.getStore()?.req as
164
- (Request & { [RPC_TIMEOUT_ABORT]?: AbortController }) | undefined
164
+ | (Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
165
+ | undefined
165
166
  req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
166
167
  }
167
168
 
@@ -0,0 +1,14 @@
1
+ /*
2
+ The array-length above which a blocking `{#await cache()}` value is worth deferring (shipped
3
+ inert, materialized on idle) rather than inlined and hydrated eagerly at boot. Deferral trades
4
+ a live boot for a cheaper one; below this many rows the boot decode is cheap enough that the
5
+ trade doesn't pay, so the block stays eager — fully interactive from the first frame, no wake
6
+ needed. Only genuinely large grids cross it and take the inert-then-idle-wake path.
7
+
8
+ An O(1) `.length` heuristic (measured on the resolved value, before serialization): array-shaped
9
+ cache reads — lists, grids, tables — are the payloads big enough to matter, and the ones the
10
+ inert path was built for. Non-array values never defer. A deliberately generous default: with
11
+ idle-wake, a deferred block is interactive anyway (live before a human acts), so this is a
12
+ boot-performance knob, not a correctness one — set high so eager-and-simple is the common case.
13
+ */
14
+ export const DEFER_MIN_ARRAY_LENGTH = 500
@@ -558,7 +558,11 @@ export function generateBuild(
558
558
  parentVar: string,
559
559
  before: string,
560
560
  ): string {
561
- return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
561
+ /* `client:idle`/`client:visible` the island trigger, the 6th mountChild arg. Omitted
562
+ (the common case) leaves the call byte-identical to before — eager hydration. */
563
+ const trigger =
564
+ node.clientTrigger !== undefined ? `, ${JSON.stringify(node.clientTrigger)}` : ''
565
+ return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)}${trigger});\n`
562
566
  }
563
567
 
564
568
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -640,12 +640,25 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
640
640
  its children become slot content (rendered where the child puts <slot>). */
641
641
  if (UPPERCASE_START.test(tag)) {
642
642
  const slotted = selfClosing ? [] : readChildren(tag)
643
+ /* `client:idle` / `client:visible` are island directives (bare) — pull them out of
644
+ the attributes so they never round-trip as props; only the build back-end reads
645
+ the trigger. Any other `client:*` falls through as a prop (a prop-check error). */
646
+ const clientAttr = attrs.find(
647
+ (attr) =>
648
+ attr.kind === 'static' &&
649
+ (attr.name === 'client:idle' || attr.name === 'client:visible'),
650
+ )
651
+ const clientTrigger =
652
+ clientAttr?.kind === 'static'
653
+ ? (clientAttr.name.slice('client:'.length) as 'idle' | 'visible')
654
+ : undefined
643
655
  return {
644
656
  kind: 'component',
645
657
  name: tag,
646
658
  loc: baseOffset + tagStart,
647
- props: toProps(attrs),
659
+ props: toProps(attrs.filter((attr) => attr !== clientAttr)),
648
660
  children: slotted,
661
+ clientTrigger,
649
662
  }
650
663
  }
651
664
  /* `{...expr}` spreads onto a component (its props) or a native element (its
@@ -80,6 +80,11 @@ export type TemplateNode =
80
80
  the prop name's source offset — the anchor for an excess-prop diagnostic. */
81
81
  props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
82
82
  children: TemplateNode[]
83
+ /* `client:idle` / `client:visible` on the tag → hydrate this child as an island
84
+ (server markup kept, build deferred to the trigger). Extracted from the attributes
85
+ so it never becomes a prop; the build back-end passes it to `mountChild`, SSR and
86
+ the prop check ignore it (an island renders fully on the server). */
87
+ clientTrigger?: 'idle' | 'visible'
83
88
  }
84
89
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
85
90
  /* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
@@ -1,5 +1,6 @@
1
1
  import { activeCacheStore } from '../shared/activeCacheStore.ts'
2
2
  import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
3
+ import { DEFER_MIN_ARRAY_LENGTH } from '../shared/DEFER_MIN_ARRAY_LENGTH.ts'
3
4
  import { snapshotShippable } from '../shared/snapshotShippable.ts'
4
5
  import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
5
6
 
@@ -24,6 +25,12 @@ export function deferResume(promise: unknown, value: unknown): ResumeEntry | Def
24
25
  if (entry === undefined || !snapshotShippable(entry)) {
25
26
  return { ok: true, value }
26
27
  }
28
+ /* Only defer a genuinely large grid: below the threshold, inline the value and hydrate
29
+ eagerly — the block is interactive from the first frame, no inert phase, no wake. This is
30
+ what keeps a modest searchable list live instead of frozen-until-idle (the reported gap). */
31
+ if (!Array.isArray(value) || value.length < DEFER_MIN_ARRAY_LENGTH) {
32
+ return { ok: true, value }
33
+ }
27
34
  entry.deferred = true
28
35
  return { defer: true, key }
29
36
  }
@@ -7,12 +7,14 @@ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
7
7
  import { RENDER } from '../runtime/RENDER.ts'
8
8
  import type { DeferMarker, ResumeEntry } from '../runtime/RESUME.ts'
9
9
  import { RESUME } from '../runtime/RESUME.ts'
10
+ import { scheduleWake } from '../runtime/scheduleWake.ts'
10
11
  import { scope } from '../runtime/scope.ts'
11
12
  import { scopeGroup } from '../runtime/scopeGroup.ts'
12
13
  import type { State } from '../runtime/types/State.ts'
13
14
  import { state } from '../state.ts'
14
15
  import { buildDetachedRange } from './buildDetachedRange.ts'
15
16
  import { discardBoundary } from './discardBoundary.ts'
17
+ import { firstElementBetween } from './firstElementBetween.ts'
16
18
  import { removeRange } from './removeRange.ts'
17
19
 
18
20
  /*
@@ -58,11 +60,20 @@ export function awaitBlock(
58
60
  let active: { start: Comment; end: Comment; dispose: () => void } | undefined
59
61
  let anchor: Node | undefined
60
62
  let first = true
63
+ /* A deferred (inert-adopted) block schedules a wake (idle or visible); cancel it if the
64
+ owner tears the block out before it fires, so no late re-run touches a detached anchor. */
65
+ let cancelWake: (() => void) | undefined
61
66
  /* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
62
67
  owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
63
68
  `{#for}`/component tears this block out is abandoned — otherwise its settle runs
64
- `place` on the block's now-detached anchor and `insertBefore` throws NotFoundError. */
65
- const guard = generationGuard()
69
+ `place` on the block's now-detached anchor and `insertBefore` throws NotFoundError.
70
+ The teardown also cancels a pending wake (above). */
71
+ const guard = generationGuard(() => cancelWake?.())
72
+ /* Flipped by a deferred block's wake. Read at the top of the effect so the block subscribes
73
+ to it and re-runs when it flips — the same re-run path an invalidate takes, so wake needs
74
+ no bespoke build logic. Never flips for a non-deferred block, so it costs one inert read
75
+ per run there. */
76
+ const woken = state(false)
66
77
  /* The resolved value, held as a reactive cell so the then-branch reads it through its
67
78
  own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
68
79
  the branch — the branch (and any keyed `each` inside it) survives and updates in place,
@@ -215,6 +226,17 @@ export function awaitBlock(
215
226
  /* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
216
227
  cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
217
228
  activeCacheStore().subscribe(key)
229
+ /* Wake the inert branch — it's a boot-frame optimization, never a lasting mode. The
230
+ wake flips `woken`, re-running the effect through its normal `render` path: the
231
+ lazily-seeded cache reads warm, so the branch materializes live and interactive with
232
+ no fetch. Closes the "deferred grid stays inert until invalidate" gap.
233
+
234
+ Position picks the trigger ('auto'): a branch with an element wakes on VISIBLE (a
235
+ below-the-fold grid decodes only when scrolled to, one never reached costs nothing);
236
+ an empty branch or a DOM with no observer wakes on IDLE — never lingering inert. */
237
+ cancelWake = scheduleWake('auto', firstElementBetween(firstKept, close), () => {
238
+ woken.value = true
239
+ })
218
240
  }
219
241
 
220
242
  /* Discard the SSR boundary and (re)build the block from the live promise, fresh
@@ -332,6 +354,9 @@ export function awaitBlock(
332
354
  }
333
355
 
334
356
  effect(() => {
357
+ /* Subscribe to the wake cell every run (including the first, before it can flip) so a
358
+ deferred block's idle wake re-runs this effect. Inert for non-deferred blocks. */
359
+ woken.value
335
360
  guard.renew()
336
361
  if (first) {
337
362
  first = false
@@ -0,0 +1,14 @@
1
+ import { isElement } from './isElement.ts'
2
+
3
+ /* The first Element in the sibling run `[start, end)` — the node a visible-wake observes for a
4
+ deferred region (await branch or component island). Undefined when the run holds no element
5
+ (text/comment only), so the caller falls back to an idle wake. Element detection is
6
+ method-based (`isElement`), not `nodeType`, so the walk runs under the test mini-dom too. */
7
+ export function firstElementBetween(start: Node | null, end: Node | null): Element | undefined {
8
+ for (let node = start; node !== null && node !== end; node = node.nextSibling) {
9
+ if (isElement(node)) {
10
+ return node
11
+ }
12
+ }
13
+ return undefined
14
+ }
@@ -29,16 +29,19 @@ export function mountChild(
29
29
  props: Parameters<UiComponent>[1],
30
30
  before: Node | null = null,
31
31
  label?: string,
32
+ /* `client:idle` / `client:visible` on the tag → hydrate this child as an island (server
33
+ markup kept, build deferred to the trigger). Undefined = hydrate eagerly, as before. */
34
+ clientTrigger?: 'idle' | 'visible',
32
35
  ): void {
33
36
  const moduleId = factory.__abideId
34
37
  if (!__ABIDE_DEV__ || !hotReloadEnabled.current || moduleId === undefined) {
35
- mountRange(parent, factory.build, props, before, label)
38
+ mountRange(parent, factory.build, props, before, label, clientTrigger)
36
39
  return
37
40
  }
38
41
  /* Capture the component's model alongside its mount handle, so a later swap can
39
42
  carry its state across (see `hotReplace`). */
40
43
  const { value: handle, model } = captureModelDoc(() =>
41
- mountRange(parent, factory.build, props, before, label),
44
+ mountRange(parent, factory.build, props, before, label, clientTrigger),
42
45
  )
43
46
  const instance = {
44
47
  factory,
@@ -1,9 +1,15 @@
1
+ import { claimChild } from '../runtime/claimChild.ts'
1
2
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scheduleWake } from '../runtime/scheduleWake.ts'
3
5
  import { scope } from '../runtime/scope.ts'
4
6
  import type { UiProps } from '../runtime/types/UiProps.ts'
7
+ import { clearBetween } from './clearBetween.ts'
8
+ import { commentData } from './commentData.ts'
5
9
  import { disposeRange } from './disposeRange.ts'
6
10
  import { fillRange } from './fillRange.ts'
11
+ import { firstElementBetween } from './firstElementBetween.ts'
12
+ import { markerDepthDelta } from './markerDepthDelta.ts'
7
13
  import { openMarker } from './openMarker.ts'
8
14
  import { withScope } from './withScope.ts'
9
15
 
@@ -22,6 +28,12 @@ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the b
22
28
  `anchorCursor`) places the range among static siblings on create; hydrate ignores it
23
29
  (the claim cursor drives placement). Returns the markers + a disposer so the hot path
24
30
  can rebuild in place.
31
+
32
+ `clientTrigger` marks the component an ISLAND: on hydrate its server markup is kept
33
+ verbatim and its build is SKIPPED, then run on the trigger (`'idle'`/`'visible'`) —
34
+ so a below-the-fold widget ships as HTML and wires no effects until scrolled to (or
35
+ the first idle gap), off the critical boot path. Create ignores it (there is no server
36
+ markup to keep — an island is a hydration-cost optimization). See `adoptIsland`.
25
37
  */
26
38
  // @documentation plumbing
27
39
  export function mountRange(
@@ -30,6 +42,7 @@ export function mountRange(
30
42
  props: UiProps | undefined,
31
43
  before: Node | null = null,
32
44
  label: string | undefined = undefined,
45
+ clientTrigger: 'idle' | 'visible' | undefined = undefined,
33
46
  ): { start: Comment; end: Comment; dispose: () => void } {
34
47
  const hydration = RENDER.hydration
35
48
  const start = openMarker(parent, RANGE_OPEN, before)
@@ -37,6 +50,9 @@ export function mountRange(
37
50
  const end = openMarker(parent, RANGE_CLOSE, before)
38
51
  return fillRange(start, end, build, props, label)
39
52
  }
53
+ if (clientTrigger !== undefined) {
54
+ return adoptIsland(parent, start, build, props, label, clientTrigger, hydration)
55
+ }
40
56
  /* Hydrate: adopt the server range in place. Establish the child's lexical scope
41
57
  and render pass (same as `fillRange`), build claiming the existing nodes, then
42
58
  claim the end marker the build's content stops before. */
@@ -44,3 +60,62 @@ export function mountRange(
44
60
  const end = openMarker(parent, RANGE_CLOSE)
45
61
  return { start, end, dispose: disposeRange(scoped, start, end) }
46
62
  }
63
+
64
+ /*
65
+ Island hydration: keep the server markup, skip the build, wake it later on the trigger. Scan the
66
+ child's server nodes to THIS range's depth-matched close (`markerDepthDelta` balances nested
67
+ `[`…`]` / `abide:` ranges) — keeping them and advancing the claim cursor past them, so the rest
68
+ of the page hydrates around the skipped subtree — then claim the close marker. No scope is built,
69
+ so the inert disposer only clears the kept nodes; the wake clears them and builds the child fresh
70
+ (hydration off) between the same markers, exactly like a create-path mount.
71
+ */
72
+ function adoptIsland(
73
+ parent: Node,
74
+ start: Comment,
75
+ build: (host: Node, props?: UiProps) => void,
76
+ props: UiProps | undefined,
77
+ label: string | undefined,
78
+ trigger: 'idle' | 'visible',
79
+ hydration: NonNullable<(typeof RENDER)['hydration']>,
80
+ ): { start: Comment; end: Comment; dispose: () => void } {
81
+ const firstKept = claimChild(hydration, parent)
82
+ let node = firstKept
83
+ let depth = 0
84
+ while (node !== null) {
85
+ const data = commentData(node)
86
+ if (data !== undefined) {
87
+ const delta = markerDepthDelta(data)
88
+ /* A depth-0 close is THIS range's own; anything nested balances out first. */
89
+ if (delta === -1 && depth === 0) {
90
+ break
91
+ }
92
+ depth += delta
93
+ }
94
+ node = node.nextSibling
95
+ }
96
+ hydration.next.set(parent, node)
97
+ const end = openMarker(parent, RANGE_CLOSE)
98
+
99
+ /* Inert: no scope/effects for the kept nodes, so disposal only evicts the range. The wake
100
+ swaps in a real build whose own disposer replaces this one. */
101
+ let disposeContent = (): void => clearBetween(start, end)
102
+ const wake = (): void => {
103
+ disposeContent()
104
+ const previous = RENDER.hydration
105
+ RENDER.hydration = undefined
106
+ try {
107
+ disposeContent = fillRange(start, end, build, props, label).dispose
108
+ } finally {
109
+ RENDER.hydration = previous
110
+ }
111
+ }
112
+ const cancelWake = scheduleWake(trigger, firstElementBetween(firstKept, end), wake)
113
+ return {
114
+ start,
115
+ end,
116
+ dispose: () => {
117
+ cancelWake()
118
+ disposeContent()
119
+ },
120
+ }
121
+ }
@@ -1,4 +1,5 @@
1
1
  import { snippet } from '../shared/snippet.ts'
2
+ import { deferResume } from './deferResume.ts'
2
3
  import { anchorCursor } from './dom/anchorCursor.ts'
3
4
  import { appendSnippet } from './dom/appendSnippet.ts'
4
5
  import { appendStatic } from './dom/appendStatic.ts'
@@ -25,7 +26,6 @@ import { spreadProps } from './dom/spreadProps.ts'
25
26
  import { switchBlock } from './dom/switchBlock.ts'
26
27
  import { tryBlock } from './dom/tryBlock.ts'
27
28
  import { when } from './dom/when.ts'
28
- import { deferResume } from './deferResume.ts'
29
29
  import { effect } from './effect.ts'
30
30
  import { enterScope } from './enterScope.ts'
31
31
  import { exitScope } from './exitScope.ts'
@@ -0,0 +1,28 @@
1
+ import { whenIdle } from './whenIdle.ts'
2
+ import { whenVisible } from './whenVisible.ts'
3
+
4
+ /*
5
+ Schedule a one-shot wake for a deferred (inert-hydrated) region by trigger — the single
6
+ trigger-selection both the await block and component islands share. `'visible'` (and `'auto'`)
7
+ wake on scroll-in when the DOM can be measured (a real IntersectionObserver) and the region has
8
+ an element to observe: a below-the-fold region decodes only when reached, one never scrolled to
9
+ costs nothing. Otherwise — `'idle'`, no observer, or an empty branch — wake on idle: off the
10
+ critical boot path but soon, so the region never lingers inert. Returns a cancel to drop a
11
+ pending wake on teardown.
12
+
13
+ `'auto'` is the await block's policy (defer decides, position decides trigger); `'idle'` /
14
+ `'visible'` are an island's explicit `client:` choice.
15
+ */
16
+ export function scheduleWake(
17
+ trigger: 'idle' | 'visible' | 'auto',
18
+ element: Element | undefined,
19
+ wake: () => void,
20
+ ): () => void {
21
+ const hasObserver =
22
+ typeof (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver ===
23
+ 'function'
24
+ if (trigger !== 'idle' && hasObserver && element !== undefined) {
25
+ return whenVisible(element, wake)
26
+ }
27
+ return whenIdle(wake)
28
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ Run `callback` once, in the first idle gap after the current frame — the client-side
3
+ trigger a deferred (inert-hydrated) region uses to wake itself: boot stays cheap (no
4
+ decode, no effects), then the region belatedly comes alive off the critical path,
5
+ before a human can act on it. `requestIdleCallback` when the runtime has it (a `timeout`
6
+ guarantees it fires even under sustained load); a `setTimeout(0)` macrotask otherwise
7
+ (Safari < 16.4, test DOMs), which still clears the boot frame. Returns a cancel to drop
8
+ the pending wake on teardown, so a region torn down before idle does no late work.
9
+ */
10
+ export function whenIdle(callback: () => void, timeoutMs = 200): () => void {
11
+ const globalWithIdle = globalThis as {
12
+ requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number
13
+ cancelIdleCallback?: (handle: number) => void
14
+ }
15
+ if (typeof globalWithIdle.requestIdleCallback === 'function') {
16
+ const handle = globalWithIdle.requestIdleCallback(callback, { timeout: timeoutMs })
17
+ return () => globalWithIdle.cancelIdleCallback?.(handle)
18
+ }
19
+ const handle = setTimeout(callback, 0)
20
+ return () => clearTimeout(handle)
21
+ }
@@ -0,0 +1,105 @@
1
+ /*
2
+ Run `callback` once, the first time `element` enters (or is already within) the viewport —
3
+ the trigger a below-the-fold deferred region uses to wake only when scrolled into view, so a
4
+ grid the user never reaches costs nothing. A synchronous fire where the observer is absent
5
+ (SSR/test DOMs), which keeps the region from staying inert where visibility can't be measured.
6
+ Returns a cancel to drop a pending watch on teardown. Fires at most once.
7
+
8
+ ONE shared IntersectionObserver backs every watcher (a grid of N islands registers N elements
9
+ on one observer, not N observers), and wakes are BATCHED off the observer callback into a
10
+ frame-flush capped at `WAKE_BUDGET_PER_FRAME`: a wake rebuilds DOM, so running it inside the
11
+ observer callback would force a synchronous layout that shifts other elements across the
12
+ margin and re-fires the observer — a cascade that locks the main thread at grid scale. Queuing
13
+ to a rAF and spending a bounded number per frame breaks that loop and keeps each frame cheap;
14
+ overflow spills to the next frame, so even a whole grid scrolled into view at once stays
15
+ responsive.
16
+ */
17
+ // A screenful of margin so a region is live by the time it's actually read.
18
+ const ROOT_MARGIN = '256px'
19
+ // Max wakes (DOM rebuilds) per frame — the ceiling that keeps a flush off the critical path.
20
+ const WAKE_BUDGET_PER_FRAME = 40
21
+
22
+ let sharedObserver: IntersectionObserver | undefined
23
+ /* The IntersectionObserver constructor `sharedObserver` was built from. Identity only changes
24
+ when a test swaps `globalThis.IntersectionObserver`; on a change the observer + its pending
25
+ registrations are rebuilt, so module state doesn't leak across tests. */
26
+ let sharedObserverCtor: unknown
27
+ const wakeByElement = new Map<Element, () => void>()
28
+ const readyWakes = new Set<() => void>()
29
+ let flushScheduled = false
30
+
31
+ /* Next animation frame (browser) or macrotask (test/SSR) — the flush cadence. */
32
+ function scheduleFrame(run: () => void): void {
33
+ const globalWithRaf = globalThis as { requestAnimationFrame?: (cb: () => void) => number }
34
+ if (typeof globalWithRaf.requestAnimationFrame === 'function') {
35
+ globalWithRaf.requestAnimationFrame(run)
36
+ } else {
37
+ setTimeout(run, 0)
38
+ }
39
+ }
40
+
41
+ /* Spend up to the per-frame budget of queued wakes; reschedule if any remain. */
42
+ function flushWakes(): void {
43
+ flushScheduled = false
44
+ let processed = 0
45
+ for (const wake of readyWakes) {
46
+ readyWakes.delete(wake)
47
+ wake()
48
+ processed += 1
49
+ if (processed >= WAKE_BUDGET_PER_FRAME) {
50
+ break
51
+ }
52
+ }
53
+ scheduleFlush()
54
+ }
55
+
56
+ function scheduleFlush(): void {
57
+ if (flushScheduled || readyWakes.size === 0) {
58
+ return
59
+ }
60
+ flushScheduled = true
61
+ scheduleFrame(flushWakes)
62
+ }
63
+
64
+ /* Queue every newly-intersecting element's wake — never run it here (see the cascade note). */
65
+ function onIntersections(entries: IntersectionObserverEntry[]): void {
66
+ for (const entry of entries) {
67
+ if (entry.isIntersecting) {
68
+ const wake = wakeByElement.get(entry.target)
69
+ if (wake !== undefined) {
70
+ wakeByElement.delete(entry.target)
71
+ sharedObserver?.unobserve(entry.target)
72
+ readyWakes.add(wake)
73
+ }
74
+ }
75
+ }
76
+ scheduleFlush()
77
+ }
78
+
79
+ function observerFor(observerConstructor: typeof IntersectionObserver): IntersectionObserver {
80
+ if (sharedObserver === undefined || sharedObserverCtor !== observerConstructor) {
81
+ wakeByElement.clear()
82
+ readyWakes.clear()
83
+ sharedObserver = new observerConstructor(onIntersections, { rootMargin: ROOT_MARGIN })
84
+ sharedObserverCtor = observerConstructor
85
+ }
86
+ return sharedObserver
87
+ }
88
+
89
+ export function whenVisible(element: Element, callback: () => void): () => void {
90
+ const observerConstructor = (
91
+ globalThis as { IntersectionObserver?: typeof IntersectionObserver }
92
+ ).IntersectionObserver
93
+ if (typeof observerConstructor !== 'function') {
94
+ callback()
95
+ return () => undefined
96
+ }
97
+ const observer = observerFor(observerConstructor)
98
+ wakeByElement.set(element, callback)
99
+ observer.observe(element)
100
+ return () => {
101
+ wakeByElement.delete(element)
102
+ readyWakes.delete(callback)
103
+ observer.unobserve(element)
104
+ }
105
+ }