@pyreon/core 0.24.4 → 0.24.6

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 (56) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +53 -31
  3. package/package.json +2 -6
  4. package/src/compat-marker.ts +0 -79
  5. package/src/compat-shared.ts +0 -80
  6. package/src/component.ts +0 -98
  7. package/src/context.ts +0 -349
  8. package/src/defer.ts +0 -279
  9. package/src/dynamic.ts +0 -32
  10. package/src/env.d.ts +0 -6
  11. package/src/error-boundary.ts +0 -90
  12. package/src/for.ts +0 -51
  13. package/src/h.ts +0 -80
  14. package/src/index.ts +0 -80
  15. package/src/jsx-dev-runtime.ts +0 -2
  16. package/src/jsx-runtime.ts +0 -747
  17. package/src/lazy.ts +0 -25
  18. package/src/lifecycle.ts +0 -152
  19. package/src/manifest.ts +0 -579
  20. package/src/map-array.ts +0 -42
  21. package/src/portal.ts +0 -39
  22. package/src/props.ts +0 -269
  23. package/src/ref.ts +0 -32
  24. package/src/show.ts +0 -121
  25. package/src/style.ts +0 -102
  26. package/src/suspense.ts +0 -52
  27. package/src/telemetry.ts +0 -120
  28. package/src/tests/compat-marker.test.ts +0 -96
  29. package/src/tests/compat-shared.test.ts +0 -99
  30. package/src/tests/component.test.ts +0 -281
  31. package/src/tests/context.test.ts +0 -629
  32. package/src/tests/core.test.ts +0 -1290
  33. package/src/tests/cx.test.ts +0 -70
  34. package/src/tests/defer.test.ts +0 -359
  35. package/src/tests/dynamic.test.ts +0 -87
  36. package/src/tests/error-boundary.test.ts +0 -181
  37. package/src/tests/extract-props-overloads.types.test.ts +0 -135
  38. package/src/tests/for.test.ts +0 -117
  39. package/src/tests/h.test.ts +0 -221
  40. package/src/tests/jsx-compat.test.tsx +0 -86
  41. package/src/tests/lazy.test.ts +0 -100
  42. package/src/tests/lifecycle.test.ts +0 -350
  43. package/src/tests/manifest-snapshot.test.ts +0 -100
  44. package/src/tests/map-array.test.ts +0 -313
  45. package/src/tests/native-marker-error-boundary.test.ts +0 -12
  46. package/src/tests/portal.test.ts +0 -48
  47. package/src/tests/props-extended.test.ts +0 -157
  48. package/src/tests/props.test.ts +0 -250
  49. package/src/tests/reactive-context.test.ts +0 -69
  50. package/src/tests/reactive-props.test.ts +0 -157
  51. package/src/tests/ref.test.ts +0 -70
  52. package/src/tests/show.test.ts +0 -314
  53. package/src/tests/style.test.ts +0 -157
  54. package/src/tests/suspense.test.ts +0 -139
  55. package/src/tests/telemetry.test.ts +0 -297
  56. package/src/types.ts +0 -116
@@ -1,70 +0,0 @@
1
- import { cx } from '../style'
2
-
3
- describe('cx', () => {
4
- test('returns empty string for falsy values', () => {
5
- expect(cx(null)).toBe('')
6
- expect(cx(undefined)).toBe('')
7
- expect(cx(false)).toBe('')
8
- expect(cx(true)).toBe('')
9
- })
10
-
11
- test('passes through strings', () => {
12
- expect(cx('foo bar')).toBe('foo bar')
13
- })
14
-
15
- test('converts numbers to strings', () => {
16
- expect(cx(42)).toBe('42')
17
- })
18
-
19
- test('filters and joins arrays', () => {
20
- expect(cx(['foo', false, 'bar', null, 'baz'])).toBe('foo bar baz')
21
- })
22
-
23
- test('resolves object keys with truthy values', () => {
24
- expect(cx({ active: true, hidden: false, bold: true })).toBe('active bold')
25
- })
26
-
27
- test('resolves object values that are functions', () => {
28
- expect(cx({ active: () => true, hidden: () => false })).toBe('active')
29
- })
30
-
31
- test('handles nested arrays and objects', () => {
32
- expect(cx(['base', { active: true }, ['nested', { deep: true }]])).toBe(
33
- 'base active nested deep',
34
- )
35
- })
36
-
37
- test('handles empty inputs', () => {
38
- expect(cx([])).toBe('')
39
- expect(cx({})).toBe('')
40
- })
41
-
42
- test('object where ALL values are functions returning booleans', () => {
43
- expect(
44
- cx({
45
- active: () => true,
46
- hidden: () => false,
47
- bold: () => true,
48
- italic: () => false,
49
- }),
50
- ).toBe('active bold')
51
- })
52
-
53
- test('deeply nested arrays (3+ levels)', () => {
54
- expect(cx([[['level3', [['level5']]]]])).toBe('level3 level5')
55
- })
56
-
57
- test('mixed: string, object with function, deeply nested array', () => {
58
- expect(cx(['base', { active: () => true }, [['deeply-nested']]])).toBe(
59
- 'base active deeply-nested',
60
- )
61
- })
62
-
63
- test('empty string values are filtered', () => {
64
- expect(cx(['foo', '', 'bar', ''])).toBe('foo bar')
65
- })
66
-
67
- test('number 0 is a valid class name as string', () => {
68
- expect(cx(0)).toBe('0')
69
- })
70
- })
@@ -1,359 +0,0 @@
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
- })
@@ -1,87 +0,0 @@
1
- import { Dynamic } from '../dynamic'
2
- import { h } from '../h'
3
- import type { ComponentFn, VNode, VNodeChild } from '../types'
4
-
5
- describe('Dynamic', () => {
6
- test('renders component function', () => {
7
- const Greeting: ComponentFn = (props) => h('span', null, (props as { name: string }).name)
8
- const result = Dynamic({ component: Greeting, name: 'world' })
9
- expect(result).not.toBeNull()
10
- expect((result as VNode).type).toBe(Greeting)
11
- expect((result as VNode).props).toEqual({ name: 'world' })
12
- })
13
-
14
- test('renders string element', () => {
15
- const result = Dynamic({ component: 'div', class: 'box', id: 'main' })
16
- expect(result).not.toBeNull()
17
- expect((result as VNode).type).toBe('div')
18
- expect((result as VNode).props).toEqual({ class: 'box', id: 'main' })
19
- })
20
-
21
- test('strips component prop from rest props', () => {
22
- const result = Dynamic({ component: 'span', id: 'x' })
23
- expect((result as VNode).props.component).toBeUndefined()
24
- expect((result as VNode).props.id).toBe('x')
25
- })
26
-
27
- test('returns null for empty string component', () => {
28
- const result = Dynamic({ component: '' })
29
- expect(result).toBeNull()
30
- })
31
-
32
- test('warns when component prop is falsy', () => {
33
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
34
- Dynamic({ component: '' })
35
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('<Dynamic>'))
36
- warnSpy.mockRestore()
37
- })
38
-
39
- test('passes all extra props to the rendered component', () => {
40
- const Comp: ComponentFn = (props) => h('div', null, JSON.stringify(props))
41
- const result = Dynamic({
42
- component: Comp,
43
- a: 1,
44
- b: 'two',
45
- c: true,
46
- })
47
- expect((result as VNode).props).toEqual({ a: 1, b: 'two', c: true })
48
- })
49
-
50
- test('renders with no extra props', () => {
51
- const result = Dynamic({ component: 'br' })
52
- expect(result).not.toBeNull()
53
- expect((result as VNode).type).toBe('br')
54
- })
55
-
56
- test('does not leak children as a prop on string-tag mount', () => {
57
- // Regression: for string `component`, runtime-dom forwards every prop
58
- // key to setAttribute. If `children` stayed in props it crashed at
59
- // mount with `setAttribute('children', ...)`. The fix re-emits them
60
- // as h() rest args, landing them in vnode.children.
61
- const result = Dynamic({ component: 'h3', children: 'hello' })
62
- expect((result as VNode).type).toBe('h3')
63
- expect((result as VNode).props.children).toBeUndefined()
64
- expect((result as VNode).children).toEqual(['hello'])
65
- })
66
-
67
- test('flattens array children to vnode.children', () => {
68
- const a = h('span', null, 'a')
69
- const b = h('span', null, 'b')
70
- const result = Dynamic({ component: 'div', children: [a, b] })
71
- expect((result as VNode).props.children).toBeUndefined()
72
- expect((result as VNode).children).toHaveLength(2)
73
- })
74
-
75
- test('component children still reach props.children at mount', () => {
76
- // For component (not string), the merge happens at mount via
77
- // mergeChildrenIntoProps — verified end-to-end by mount tests in
78
- // runtime-dom. Here we just confirm the vnode shape is correct so the
79
- // merge will fire (children must be on vnode.children, not props).
80
- const Comp: ComponentFn = (props) =>
81
- h('div', null, (props as { children?: VNodeChild }).children ?? null)
82
- const result = Dynamic({ component: Comp, children: 'hi' })
83
- expect((result as VNode).type).toBe(Comp)
84
- expect((result as VNode).props.children).toBeUndefined()
85
- expect((result as VNode).children).toEqual(['hi'])
86
- })
87
- })
@@ -1,181 +0,0 @@
1
- import { dispatchToErrorBoundary, popErrorBoundary, runWithHooks } from '../component'
2
- import { ErrorBoundary } from '../error-boundary'
3
- import { h } from '../h'
4
- import type { VNodeChild } from '../types'
5
-
6
- describe('ErrorBoundary', () => {
7
- // Clean up error boundary stack after each test
8
- afterEach(() => {
9
- // Pop all boundaries that tests may have left
10
- while (dispatchToErrorBoundary('cleanup')) {
11
- popErrorBoundary()
12
- }
13
- })
14
-
15
- test('is a function', () => {
16
- expect(typeof ErrorBoundary).toBe('function')
17
- })
18
-
19
- test('returns a reactive getter', () => {
20
- let result: VNodeChild = null
21
- runWithHooks(() => {
22
- result = ErrorBoundary({
23
- fallback: (err) => `Error: ${err}`,
24
- children: 'child',
25
- })
26
- return null
27
- }, {})
28
- expect(typeof result).toBe('function')
29
- })
30
-
31
- test('renders children when no error', () => {
32
- let result: VNodeChild = null
33
- runWithHooks(() => {
34
- result = ErrorBoundary({
35
- fallback: (err) => `Error: ${err}`,
36
- children: 'child content',
37
- })
38
- return null
39
- }, {})
40
- const getter = result as unknown as () => VNodeChild
41
- expect(getter()).toBe('child content')
42
- })
43
-
44
- test('renders function children by calling them', () => {
45
- let result: VNodeChild = null
46
- runWithHooks(() => {
47
- result = ErrorBoundary({
48
- fallback: (err) => `Error: ${err}`,
49
- children: () => 'dynamic child',
50
- })
51
- return null
52
- }, {})
53
- const getter = result as unknown as () => VNodeChild
54
- expect(getter()).toBe('dynamic child')
55
- })
56
-
57
- test('renders VNode children', () => {
58
- let result: VNodeChild = null
59
- const child = h('div', null, 'content')
60
- runWithHooks(() => {
61
- result = ErrorBoundary({
62
- fallback: (err) => `Error: ${err}`,
63
- children: child,
64
- })
65
- return null
66
- }, {})
67
- const getter = result as unknown as () => VNodeChild
68
- expect(getter()).toBe(child)
69
- })
70
-
71
- test('registers unmount cleanup hook', () => {
72
- const { hooks } = runWithHooks(() => {
73
- ErrorBoundary({
74
- fallback: (err) => `Error: ${err}`,
75
- children: 'child',
76
- })
77
- return null
78
- }, {})
79
- expect(hooks.unmount!.length).toBeGreaterThanOrEqual(1)
80
- })
81
-
82
- test('warns when fallback is not a function', () => {
83
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
84
- runWithHooks(() => {
85
- ErrorBoundary({
86
- fallback: 'not-a-function' as unknown as (err: unknown, reset: () => void) => VNodeChild,
87
- children: 'child',
88
- })
89
- return null
90
- }, {})
91
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('<ErrorBoundary>'))
92
- warnSpy.mockRestore()
93
- })
94
-
95
- test('dispatched error triggers fallback rendering', () => {
96
- let result: VNodeChild = null
97
- runWithHooks(() => {
98
- result = ErrorBoundary({
99
- fallback: (err) => `Caught: ${err}`,
100
- children: 'normal',
101
- })
102
- return null
103
- }, {})
104
- const getter = result as unknown as () => VNodeChild
105
- expect(getter()).toBe('normal')
106
-
107
- dispatchToErrorBoundary(new Error('boom'))
108
- expect(getter()).toBe('Caught: Error: boom')
109
- })
110
-
111
- test('fallback receives reset function that clears error', () => {
112
- let result: VNodeChild = null
113
- let capturedReset: (() => void) | undefined
114
- runWithHooks(() => {
115
- result = ErrorBoundary({
116
- fallback: (_err, reset) => {
117
- capturedReset = reset
118
- return 'error-ui'
119
- },
120
- children: 'child',
121
- })
122
- return null
123
- }, {})
124
- const getter = result as unknown as () => VNodeChild
125
- expect(getter()).toBe('child')
126
-
127
- dispatchToErrorBoundary('test-error')
128
- expect(getter()).toBe('error-ui')
129
- expect(capturedReset).toBeDefined()
130
-
131
- capturedReset?.()
132
- expect(getter()).toBe('child')
133
- })
134
-
135
- test('second error while already in error state is not handled', () => {
136
- let result: VNodeChild = null
137
- runWithHooks(() => {
138
- result = ErrorBoundary({
139
- fallback: (err) => `Error: ${err}`,
140
- children: 'child',
141
- })
142
- return null
143
- }, {})
144
- const getter = result as unknown as () => VNodeChild
145
-
146
- // First error handled
147
- expect(dispatchToErrorBoundary('first')).toBe(true)
148
- expect(getter()).toBe('Error: first')
149
-
150
- // Second error not handled (already in error state)
151
- expect(dispatchToErrorBoundary('second')).toBe(false)
152
- // Still showing first error
153
- expect(getter()).toBe('Error: first')
154
- })
155
-
156
- test('after reset, new error can be caught again', () => {
157
- let result: VNodeChild = null
158
- let capturedReset: (() => void) | undefined
159
- runWithHooks(() => {
160
- result = ErrorBoundary({
161
- fallback: (err, reset) => {
162
- capturedReset = reset
163
- return `Error: ${err}`
164
- },
165
- children: 'child',
166
- })
167
- return null
168
- }, {})
169
- const getter = result as unknown as () => VNodeChild
170
-
171
- dispatchToErrorBoundary('first-error')
172
- expect(getter()).toBe('Error: first-error')
173
-
174
- capturedReset?.()
175
- expect(getter()).toBe('child')
176
-
177
- // Can catch new error after reset
178
- expect(dispatchToErrorBoundary('second-error')).toBe(true)
179
- expect(getter()).toBe('Error: second-error')
180
- })
181
- })