@pyreon/core 0.16.0 → 0.19.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.
@@ -25,19 +25,57 @@ export function jsx(
25
25
  props: Props & { children?: VNodeChild | VNodeChild[] },
26
26
  key?: string | number | null,
27
27
  ): VNode {
28
- const { children, ...rest } = props
29
- const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
28
+ // Build the destructured props object by copying own property
29
+ // DESCRIPTORS, not values. Compiler-emitted reactive props (`_rp(() =>
30
+ // signal())` wrappers converted to getter properties by
31
+ // `makeReactiveProps` in mount.ts) MUST survive the destructure with
32
+ // their getters intact. A plain `{ children, ...rest } = props`
33
+ // destructure fires every getter on `props` and stores the resolved
34
+ // value, breaking signal-driven reactivity for any downstream
35
+ // consumer that reads `props.x` in a tracking scope.
36
+ //
37
+ // Fast path: if `props` has no own property descriptors with `get`
38
+ // accessors, we can use the original value-copy shape (cheap object
39
+ // literal allocation). This is the 99% case — only framework wrappers
40
+ // (rocketstyle attrs HOC, Wrapper, styled) and direct signal props
41
+ // produce getter-shaped descriptors.
42
+ const descriptors = Object.getOwnPropertyDescriptors(props)
43
+ let hasGetter = false
44
+ for (const k in descriptors) {
45
+ if (descriptors[k]!.get) {
46
+ hasGetter = true
47
+ break
48
+ }
49
+ }
50
+ const children = props.children
51
+
52
+ if (!hasGetter) {
53
+ const { children: _ignored, ...rest } = props
54
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
55
+ if (typeof type === 'function') {
56
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
57
+ return h(type, componentProps)
58
+ }
59
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
60
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
61
+ }
62
+
63
+ // Slow path: at least one getter descriptor present — preserve
64
+ // descriptors during the destructure.
65
+ const propsWithKey: Record<string, unknown> = {}
66
+ for (const k in descriptors) {
67
+ if (k === 'children') continue
68
+ Object.defineProperty(propsWithKey, k, descriptors[k]!)
69
+ }
70
+ if (key != null) propsWithKey.key = key as unknown
30
71
 
31
72
  if (typeof type === 'function') {
32
- // Component: keep children in props.children so the component function can access them.
33
- // Children must NOT be spread as h() rest args because mountComponent only passes vnode.props.
34
- const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
35
- return h(type, componentProps)
73
+ if (children !== undefined) propsWithKey.children = children
74
+ return h(type, propsWithKey as Props)
36
75
  }
37
76
 
38
- // DOM element or symbol (Fragment, ForSymbol): children go in vnode.children
39
77
  const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
40
- return h(type, propsWithKey, ...(childArray as VNodeChild[]))
78
+ return h(type, propsWithKey as Props, ...(childArray as VNodeChild[]))
41
79
  }
42
80
 
43
81
  // jsxs is called when there are multiple static children — same signature
package/src/lifecycle.ts CHANGED
@@ -59,12 +59,14 @@ function captureCallSite(): string {
59
59
  function warnOutsideSetup(hookName: string): void {
60
60
  if (__DEV__ && !_current) {
61
61
  const callSite = captureCallSite()
62
- const location = callSite ? `\n Called from: ${callSite}` : ''
62
+ // Local name must NOT shadow the `location` browser global (poor
63
+ // hygiene + trips SSR static analysis into a false positive).
64
+ const callSiteSuffix = callSite ? `\n Called from: ${callSite}` : ''
63
65
  // oxlint-disable-next-line no-console
64
66
  console.warn(
65
67
  `[Pyreon] ${hookName}() called outside component setup. ` +
66
68
  "Lifecycle hooks must be called synchronously during a component's setup function." +
67
- location +
69
+ callSiteSuffix +
68
70
  (hookName === 'onUnmount'
69
71
  ? '\n Hint: `provide()` internally calls onUnmount(). If you use provide(), ensure it runs during synchronous component setup — not inside effects, callbacks, or after awaits.'
70
72
  : ''),
package/src/props.ts CHANGED
@@ -140,6 +140,65 @@ export function _rp<T>(fn: () => T): () => T {
140
140
  return fn
141
141
  }
142
142
 
143
+ /**
144
+ * Wrap a JSX spread source so its getter-shaped reactive props survive
145
+ * the JS-level object spread that esbuild's automatic JSX runtime emits
146
+ * for `<Comp {...source}>`.
147
+ *
148
+ * Without this wrapper, esbuild compiles `<Comp {...source}>` to
149
+ * `jsx(Comp, { ...source })` — and JS spread fires every getter on
150
+ * `source`, storing the resolved values as plain data properties. Any
151
+ * compiler-emitted reactive prop (`_rp(() => signal())` converted to a
152
+ * getter by `makeReactiveProps`) on `source` is collapsed to its
153
+ * initial value before the receiving component ever sees it.
154
+ *
155
+ * `_wrapSpread(source)` walks `source`'s own keys via `Reflect.ownKeys`
156
+ * (no getter firing) and returns a new object whose values are
157
+ * `_rp`-branded thunks `() => source[key]`. When `{ ..._wrapSpread(s) }`
158
+ * is spread by esbuild, the thunks are stored as plain data property
159
+ * values (no getters to fire), then `makeReactiveProps` in `mount.ts`
160
+ * converts the brands back into getters that lazily read from the
161
+ * original `source` — preserving the reactive subscription end-to-end.
162
+ *
163
+ * Fast path: when `source` has no getter descriptors, return the
164
+ * source object unchanged. JS spread will work correctly in that case
165
+ * because there's nothing reactive to preserve. Saves N thunk
166
+ * allocations per component render in the 99% case.
167
+ *
168
+ * Emitted by the compiler — not generally meant for hand-written code.
169
+ */
170
+ export function _wrapSpread(
171
+ source: Record<string, unknown> | null | undefined,
172
+ ): Record<string, unknown> | null | undefined {
173
+ if (!source || typeof source !== 'object') return source
174
+ const descriptors = Object.getOwnPropertyDescriptors(source)
175
+ let hasGetter = false
176
+ for (const k in descriptors) {
177
+ if (descriptors[k]!.get) {
178
+ hasGetter = true
179
+ break
180
+ }
181
+ }
182
+ if (!hasGetter) return source
183
+
184
+ const result: Record<string, unknown> = {}
185
+ // Reflect.ownKeys covers symbol keys too — REACTIVE_PROP brands and
186
+ // other framework symbols must round-trip through the wrap.
187
+ for (const key of Reflect.ownKeys(source)) {
188
+ const desc = descriptors[key as string]
189
+ if (!desc) continue
190
+ if (desc.get) {
191
+ const fn: () => unknown = () => source[key as string]
192
+ ;(fn as unknown as Record<symbol, boolean>)[REACTIVE_PROP] = true
193
+ result[key as string] = fn
194
+ } else {
195
+ // Static data property — copy through as-is.
196
+ result[key as string] = desc.value
197
+ }
198
+ }
199
+ return result
200
+ }
201
+
143
202
  /**
144
203
  * Convert compiler-emitted `_rp(() => expr)` prop values into getter properties.
145
204
  *
package/src/telemetry.ts CHANGED
@@ -16,6 +16,13 @@
16
16
  * })
17
17
  */
18
18
 
19
+ import { getReactiveTrace, type ReactiveTraceEntry } from '@pyreon/reactivity'
20
+
21
+ // Bundler-agnostic dev gate (see pyreon/no-process-dev-gate).
22
+ const __DEV__ = process.env.NODE_ENV !== 'production'
23
+
24
+ export type { ReactiveTraceEntry }
25
+
19
26
  export interface ErrorContext {
20
27
  /** Component function name, "Anonymous", or "Effect" for reactive effects */
21
28
  component: string
@@ -27,6 +34,22 @@ export interface ErrorContext {
27
34
  timestamp: number
28
35
  /** Component props at the time of the error */
29
36
  props?: Record<string, unknown>
37
+ /**
38
+ * The last N signal writes (chronological, oldest → newest) leading
39
+ * up to the error — the causal sequence of reactive state changes,
40
+ * not a point-in-time snapshot. Each entry is `{ name, prev, next,
41
+ * timestamp }` with `prev` / `next` as bounded string previews.
42
+ *
43
+ * Populated automatically in development from `@pyreon/reactivity`'s
44
+ * dev-only ring buffer. **`undefined` in production** — the recorder
45
+ * feeding the buffer tree-shakes out of prod bundles, so the cost is
46
+ * zero and the field is simply absent.
47
+ *
48
+ * For a signal framework this answers the first question a crash
49
+ * raises — "what reactive state changed in the run-up?" — that the
50
+ * thrown value + stack alone can't.
51
+ */
52
+ reactiveTrace?: ReactiveTraceEntry[]
30
53
  }
31
54
 
32
55
  export type ErrorHandler = (ctx: ErrorContext) => void
@@ -56,6 +79,20 @@ export function registerErrorHandler(handler: ErrorHandler): () => void {
56
79
  * Existing console.error calls are preserved; this is additive.
57
80
  */
58
81
  export function reportError(ctx: ErrorContext): void {
82
+ // Enrich with the recent-signal-write trace so every handler (Sentry,
83
+ // Datadog, console) gets the causal reactive sequence for free. Only
84
+ // when the caller didn't already supply one, and only in dev — the
85
+ // gate lets the `getReactiveTrace` call (and the buffer behind it)
86
+ // tree-shake out of production. A throwing/empty trace must never
87
+ // block error reporting, so it's best-effort.
88
+ if (__DEV__ && ctx.reactiveTrace === undefined) {
89
+ try {
90
+ const trace = getReactiveTrace()
91
+ if (trace.length > 0) ctx.reactiveTrace = trace
92
+ } catch {
93
+ // Trace capture is diagnostic — never let it swallow the real error.
94
+ }
95
+ }
59
96
  for (const h of _handlers) {
60
97
  try {
61
98
  h(ctx)
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mapCompatDomProps, shallowEqualProps } from '../compat-shared'
3
+
4
+ describe('shallowEqualProps', () => {
5
+ it('equal for same-key same-value objects', () => {
6
+ expect(shallowEqualProps({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
7
+ })
8
+
9
+ it('not equal when a value differs', () => {
10
+ expect(shallowEqualProps({ a: 1 }, { a: 2 })).toBe(false)
11
+ })
12
+
13
+ it('not equal when key counts differ', () => {
14
+ expect(shallowEqualProps({ a: 1 }, { a: 1, b: 2 })).toBe(false)
15
+ })
16
+
17
+ it('uses Object.is semantics (NaN equal, ±0 distinct)', () => {
18
+ expect(shallowEqualProps({ n: NaN }, { n: NaN })).toBe(true)
19
+ expect(shallowEqualProps({ z: 0 }, { z: -0 })).toBe(false)
20
+ })
21
+
22
+ it('empty objects are equal', () => {
23
+ expect(shallowEqualProps({}, {})).toBe(true)
24
+ })
25
+ })
26
+
27
+ describe('mapCompatDomProps', () => {
28
+ it('no-op for component (non-string) type', () => {
29
+ const Comp = () => null
30
+ const p: Record<string, unknown> = { className: 'x', htmlFor: 'y' }
31
+ mapCompatDomProps(p, Comp)
32
+ expect(p).toEqual({ className: 'x', htmlFor: 'y' })
33
+ })
34
+
35
+ it('className → class, htmlFor → for', () => {
36
+ const p: Record<string, unknown> = { className: 'btn', htmlFor: 'email' }
37
+ mapCompatDomProps(p, 'label')
38
+ expect(p).toEqual({ class: 'btn', for: 'email' })
39
+ })
40
+
41
+ it('onChange → onInput on input/textarea/select', () => {
42
+ for (const tag of ['input', 'textarea', 'select']) {
43
+ const fn = () => {}
44
+ const p: Record<string, unknown> = { onChange: fn }
45
+ mapCompatDomProps(p, tag)
46
+ expect(p).toEqual({ onInput: fn })
47
+ }
48
+ })
49
+
50
+ it('onChange does not clobber an explicit onInput', () => {
51
+ const onChange = () => {}
52
+ const onInput = () => {}
53
+ const p: Record<string, unknown> = { onChange, onInput }
54
+ mapCompatDomProps(p, 'input')
55
+ expect(p).toEqual({ onInput })
56
+ })
57
+
58
+ it('onChange left alone on non-form elements', () => {
59
+ const onChange = () => {}
60
+ const p: Record<string, unknown> = { onChange }
61
+ mapCompatDomProps(p, 'div')
62
+ expect(p).toEqual({ onChange })
63
+ })
64
+
65
+ it('autoFocus → autofocus', () => {
66
+ const p: Record<string, unknown> = { autoFocus: true }
67
+ mapCompatDomProps(p, 'input')
68
+ expect(p).toEqual({ autofocus: true })
69
+ })
70
+
71
+ it('defaultValue/defaultChecked → value/checked only when uncontrolled', () => {
72
+ const a: Record<string, unknown> = { defaultValue: 'd', defaultChecked: true }
73
+ mapCompatDomProps(a, 'input')
74
+ expect(a).toEqual({ value: 'd', checked: true })
75
+
76
+ const b: Record<string, unknown> = {
77
+ defaultValue: 'd',
78
+ value: 'controlled',
79
+ defaultChecked: true,
80
+ checked: false,
81
+ }
82
+ mapCompatDomProps(b, 'input')
83
+ expect(b).toEqual({
84
+ defaultValue: 'd',
85
+ value: 'controlled',
86
+ defaultChecked: true,
87
+ checked: false,
88
+ })
89
+ })
90
+
91
+ it('strips authoring-only props with no DOM equivalent', () => {
92
+ const p: Record<string, unknown> = {
93
+ suppressHydrationWarning: true,
94
+ suppressContentEditableWarning: true,
95
+ }
96
+ mapCompatDomProps(p, 'div')
97
+ expect(p).toEqual({})
98
+ })
99
+ })
@@ -0,0 +1,359 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { _setupIdleTrigger, _setupVisibleTrigger, Defer } from '../defer'
3
+ import { Fragment, h } from '../h'
4
+ import type { ComponentFn, Props, VNode } from '../types'
5
+
6
+ // Helper: pull the render-callback out of Defer's returned VNode shape.
7
+ // `when` and `idle` modes return a Fragment whose `children[0]` is a
8
+ // thunk. `visible` mode returns a div whose `children[0]` is the same
9
+ // thunk. Both shapes return the same `renderContent` accessor.
10
+ function getRenderThunk(vnode: VNode): () => unknown {
11
+ const children = vnode.children as unknown[]
12
+ const thunk = children[0]
13
+ if (typeof thunk !== 'function') throw new Error('Expected render thunk')
14
+ return thunk as () => unknown
15
+ }
16
+
17
+ describe('Defer — common shape', () => {
18
+ test('returns a VNode (Fragment or wrapper div per trigger mode)', () => {
19
+ const result = Defer({
20
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
21
+ when: () => false,
22
+ })
23
+ expect(result).toBeDefined()
24
+ expect((result as VNode).type).toBe(Fragment)
25
+ })
26
+
27
+ test('renders fallback before chunk resolves', () => {
28
+ const fallback = h('span', null, 'loading…')
29
+ const vnode = Defer<Props>({
30
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}), // never resolves
31
+ when: () => true,
32
+ fallback,
33
+ })
34
+ expect(getRenderThunk(vnode)()).toBe(fallback)
35
+ })
36
+
37
+ test('renders null when no fallback and chunk has not resolved', () => {
38
+ const vnode = Defer<Props>({
39
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
40
+ when: () => true,
41
+ })
42
+ expect(getRenderThunk(vnode)()).toBeNull()
43
+ })
44
+ })
45
+
46
+ describe('Defer — when (signal-driven)', () => {
47
+ test('does NOT load chunk while when is false', () => {
48
+ let calls = 0
49
+ const chunkFn = () => {
50
+ calls++
51
+ return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
52
+ }
53
+ const flag = signal(false)
54
+ Defer<Props>({ chunk: chunkFn, when: flag })
55
+ expect(calls).toBe(0)
56
+ })
57
+
58
+ test('loads chunk when when flips to true', async () => {
59
+ const Inner: ComponentFn<{ msg: string }> = (p) => h('div', null, p.msg)
60
+ let calls = 0
61
+ const chunkFn = () => {
62
+ calls++
63
+ return Promise.resolve({ default: Inner })
64
+ }
65
+ const flag = signal(false)
66
+ const vnode = Defer<{ msg: string }>({
67
+ chunk: chunkFn,
68
+ when: flag,
69
+ children: (Comp) => h(Comp, { msg: 'hi' }),
70
+ })
71
+ expect(calls).toBe(0)
72
+ flag.set(true)
73
+ // Effect schedules synchronously; chunk fetch is microtask-resolved.
74
+ expect(calls).toBe(1)
75
+ await new Promise((r) => setTimeout(r, 0))
76
+ const result = getRenderThunk(vnode)() as VNode
77
+ expect(result.type).toBe(Inner)
78
+ expect(result.props).toEqual({ msg: 'hi' })
79
+ })
80
+
81
+ test('loads chunk EXACTLY ONCE when signal oscillates', async () => {
82
+ let calls = 0
83
+ const chunkFn = () => {
84
+ calls++
85
+ return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
86
+ }
87
+ const flag = signal(false)
88
+ Defer<Props>({ chunk: chunkFn, when: flag })
89
+ flag.set(true)
90
+ flag.set(false)
91
+ flag.set(true)
92
+ flag.set(false)
93
+ flag.set(true)
94
+ await new Promise((r) => setTimeout(r, 0))
95
+ expect(calls).toBe(1)
96
+ })
97
+
98
+ test('accepts component re-exports without default wrapper', async () => {
99
+ const Inner: ComponentFn = () => h('span', null, 'ok')
100
+ const flag = signal(true)
101
+ const vnode = Defer<Props>({
102
+ chunk: () => Promise.resolve(Inner), // bare ComponentFn
103
+ when: flag,
104
+ children: (Comp) => h(Comp, {}),
105
+ })
106
+ await new Promise((r) => setTimeout(r, 0))
107
+ const result = getRenderThunk(vnode)() as VNode
108
+ expect(result.type).toBe(Inner)
109
+ })
110
+
111
+ test('throws when chunk() rejects (Suspense-style error propagation)', async () => {
112
+ const consoleSpy = (() => {
113
+ const orig = console.error
114
+ console.error = () => {} // silence dev-mode error log
115
+ return () => {
116
+ console.error = orig
117
+ }
118
+ })()
119
+ try {
120
+ const flag = signal(true)
121
+ const vnode = Defer<Props>({
122
+ chunk: () => Promise.reject(new Error('chunk boom')),
123
+ when: flag,
124
+ })
125
+ await new Promise((r) => setTimeout(r, 0))
126
+ expect(() => getRenderThunk(vnode)()).toThrow('chunk boom')
127
+ } finally {
128
+ consoleSpy()
129
+ }
130
+ })
131
+
132
+ test('renders default <Comp /> when children render-prop omitted', async () => {
133
+ const Inner: ComponentFn = () => h('div', null, 'no-children-prop')
134
+ const flag = signal(true)
135
+ const vnode = Defer<Props>({
136
+ chunk: () => Promise.resolve({ default: Inner }),
137
+ when: flag,
138
+ })
139
+ await new Promise((r) => setTimeout(r, 0))
140
+ const result = getRenderThunk(vnode)() as VNode
141
+ expect(result.type).toBe(Inner)
142
+ expect(result.props).toEqual({})
143
+ })
144
+ })
145
+
146
+ describe('Defer — on="visible"', () => {
147
+ test('returns a div wrapper with data-pyreon-defer="visible"', () => {
148
+ const vnode = Defer<Props>({
149
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
150
+ on: 'visible',
151
+ })
152
+ expect((vnode as VNode).type).toBe('div')
153
+ expect((vnode as VNode).props['data-pyreon-defer']).toBe('visible')
154
+ })
155
+
156
+ test('uses display: contents so wrapper is layout-transparent', () => {
157
+ const vnode = Defer<Props>({
158
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
159
+ on: 'visible',
160
+ })
161
+ expect((vnode as VNode).props.style).toBe('display: contents')
162
+ })
163
+
164
+ test('default rootMargin is 200px (not exposed via prop spread)', () => {
165
+ // The rootMargin is consumed by onMount; we can't directly observe
166
+ // it from the returned VNode. This test documents the default and
167
+ // would catch a regression in the constant.
168
+ const vnode = Defer<Props>({
169
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
170
+ on: 'visible',
171
+ })
172
+ // Wrapper has the structural attrs but no rootMargin leak to DOM.
173
+ expect((vnode as VNode).props.rootMargin).toBeUndefined()
174
+ })
175
+ })
176
+
177
+ describe('Defer — on="idle"', () => {
178
+ test('returns a Fragment (no wrapper element)', () => {
179
+ const vnode = Defer<Props>({
180
+ chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
181
+ on: 'idle',
182
+ })
183
+ expect((vnode as VNode).type).toBe(Fragment)
184
+ })
185
+ })
186
+
187
+ // Browser-API helpers extracted from the onMount callbacks so they're
188
+ // directly testable without happy-dom (core tests run in Node). The
189
+ // onMount wrappers in `defer.ts` just delegate to these.
190
+ describe('_setupIdleTrigger', () => {
191
+ // Use `Reflect.has` + property descriptors instead of plain assignment
192
+ // so the restore is symmetric — if the global wasn't defined at test
193
+ // start (Node), `delete` cleanly returns to the original state.
194
+ const orig = {
195
+ ric: (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback,
196
+ cic: (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback,
197
+ }
198
+ afterEach(() => {
199
+ if (orig.ric === undefined) delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
200
+ else (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = orig.ric
201
+ if (orig.cic === undefined) delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
202
+ else (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = orig.cic
203
+ })
204
+
205
+ test('uses requestIdleCallback when available', () => {
206
+ let captured: (() => void) | null = null
207
+ ;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
208
+ cb: () => void,
209
+ ): number => {
210
+ captured = cb
211
+ return 42
212
+ }
213
+ let cancelledId: number | null = null
214
+ ;(globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = (
215
+ id: number,
216
+ ): void => {
217
+ cancelledId = id
218
+ }
219
+ const startLoad = () => {}
220
+ const teardown = _setupIdleTrigger(startLoad)
221
+ expect(captured).toBe(startLoad)
222
+ teardown()
223
+ expect(cancelledId).toBe(42)
224
+ })
225
+
226
+ test('teardown is no-op when cancelIdleCallback is missing', () => {
227
+ ;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
228
+ _cb: () => void,
229
+ ): number => 99
230
+ delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
231
+ const teardown = _setupIdleTrigger(() => {})
232
+ expect(() => teardown()).not.toThrow()
233
+ })
234
+
235
+ test('falls back to setTimeout when requestIdleCallback is absent', async () => {
236
+ delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
237
+ let loaded = false
238
+ const teardown = _setupIdleTrigger(() => {
239
+ loaded = true
240
+ })
241
+ // setTimeout(fn, 1) fires after a tick — await to observe.
242
+ await new Promise((r) => setTimeout(r, 5))
243
+ expect(loaded).toBe(true)
244
+ teardown() // safe to call after the timer fired
245
+ })
246
+
247
+ test('setTimeout fallback cancels via clearTimeout when teardown fires before tick', () => {
248
+ delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
249
+ let loaded = false
250
+ const teardown = _setupIdleTrigger(() => {
251
+ loaded = true
252
+ })
253
+ teardown() // cancel before the timer fires
254
+ return new Promise<void>((r) =>
255
+ setTimeout(() => {
256
+ expect(loaded).toBe(false)
257
+ r()
258
+ }, 10),
259
+ )
260
+ })
261
+ })
262
+
263
+ describe('_setupVisibleTrigger', () => {
264
+ const origObs = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
265
+ afterEach(() => {
266
+ if (origObs === undefined) delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
267
+ else (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = origObs
268
+ })
269
+
270
+ test('loads immediately when el is null', () => {
271
+ let loaded = false
272
+ const teardown = _setupVisibleTrigger(null, () => {
273
+ loaded = true
274
+ }, '200px')
275
+ expect(loaded).toBe(true)
276
+ expect(typeof teardown).toBe('function')
277
+ })
278
+
279
+ test('loads immediately when IntersectionObserver is unavailable', () => {
280
+ delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
281
+ // Pass a stub element; without the global the trigger should bail early.
282
+ const stubEl = {} as unknown as HTMLElement
283
+ let loaded = false
284
+ _setupVisibleTrigger(stubEl, () => {
285
+ loaded = true
286
+ }, '200px')
287
+ expect(loaded).toBe(true)
288
+ })
289
+
290
+ test('creates an observer with the configured rootMargin', () => {
291
+ let capturedOptions: IntersectionObserverInit | undefined
292
+ let observed: Element | null = null
293
+ let disconnected = false
294
+ class StubObserver {
295
+ callback: IntersectionObserverCallback
296
+ constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) {
297
+ this.callback = cb
298
+ capturedOptions = opts
299
+ }
300
+ observe(el: Element) {
301
+ observed = el
302
+ }
303
+ disconnect() {
304
+ disconnected = true
305
+ }
306
+ unobserve() {}
307
+ takeRecords() {
308
+ return []
309
+ }
310
+ }
311
+ ;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
312
+ const stubEl = { tagName: 'DIV' } as unknown as HTMLElement
313
+ const teardown = _setupVisibleTrigger(stubEl, () => {}, '300px')
314
+ expect(observed).toBe(stubEl)
315
+ expect(capturedOptions?.rootMargin).toBe('300px')
316
+ teardown()
317
+ expect(disconnected).toBe(true)
318
+ })
319
+
320
+ test('fires startLoad on intersection, then disconnects', () => {
321
+ let captured: IntersectionObserverCallback | null = null
322
+ let disconnected = false
323
+ class StubObserver {
324
+ constructor(cb: IntersectionObserverCallback) {
325
+ captured = cb
326
+ }
327
+ observe() {}
328
+ disconnect() {
329
+ disconnected = true
330
+ }
331
+ unobserve() {}
332
+ takeRecords() {
333
+ return []
334
+ }
335
+ }
336
+ ;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
337
+ let loaded = false
338
+ const stubEl = {} as unknown as HTMLElement
339
+ _setupVisibleTrigger(stubEl, () => {
340
+ loaded = true
341
+ }, '0px')
342
+
343
+ // Simulate non-intersecting entry — should NOT fire.
344
+ captured!(
345
+ [{ isIntersecting: false } as unknown as IntersectionObserverEntry],
346
+ {} as IntersectionObserver,
347
+ )
348
+ expect(loaded).toBe(false)
349
+ expect(disconnected).toBe(false)
350
+
351
+ // Simulate intersecting entry — fires and disconnects.
352
+ captured!(
353
+ [{ isIntersecting: true } as unknown as IntersectionObserverEntry],
354
+ {} as IntersectionObserver,
355
+ )
356
+ expect(loaded).toBe(true)
357
+ expect(disconnected).toBe(true)
358
+ })
359
+ })