import { describe, it, expect, vi } from 'vitest'
import { createRoot } from '../lib/vdom.ts'
import { on } from '../index.ts'
import type { Handle } from '../lib/component.ts'
describe('vdom error handling', () => {
describe('root event forwarding', () => {
it('forwards bubbling DOM error events to root listeners', () => {
let container = document.createElement('div')
let root = createRoot(container)
let forwarded: unknown
root.addEventListener('error', (event) => {
forwarded = (event as ErrorEvent).error
})
let expected = new Error('createRoot forwarded error')
container.dispatchEvent(new ErrorEvent('error', { bubbles: true, error: expected }))
expect(forwarded).toBe(expected)
})
it('stops forwarding bubbling DOM error events after dispose', () => {
let container = document.createElement('div')
let root = createRoot(container)
let forwarded: unknown
root.addEventListener('error', (event) => {
forwarded = (event as ErrorEvent).error
})
root.dispose()
container.dispatchEvent(
new ErrorEvent('error', { bubbles: true, error: new Error('after dispose') }),
)
expect(forwarded).toBeUndefined()
})
it('dispose is a no-op before first render', () => {
let container = document.createElement('div')
let root = createRoot(container)
root.dispose()
root.flush()
expect(container.innerHTML).toBe('')
})
})
describe('setup errors', () => {
it('dispatches error event when setup throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let error = new Error('setup error')
function BadComponent() {
throw error
return () =>
ok
}
root.render()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
it('dispatches error event when nested component setup throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let error = new Error('nested setup error')
function BadChild() {
throw error
return () => null
}
function Parent() {
return () => (
)
}
root.render()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
})
describe('render errors', () => {
it('dispatches error event when render function throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let error = new Error('render error')
function BadComponent() {
return () => {
throw error
}
}
root.render()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
it('dispatches error event when render throws on update', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let shouldThrow = false
let error = new Error('render update error')
let update: () => void
function Component(handle: Handle) {
update = () => handle.update()
return () => {
if (shouldThrow) throw error
return ok
}
}
root.render()
expect(container.innerHTML).toBe('ok
')
expect(errorHandler).not.toHaveBeenCalled()
shouldThrow = true
update!()
root.flush()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
})
describe('event handler errors', () => {
it('runs sync event handlers attached via on() mixin', () => {
let container = document.createElement('div')
let root = createRoot(container)
let clicks = 0
root.render(
,
)
root.flush()
let button = container.querySelector('button')!
button.click()
expect(clicks).toBe(1)
})
it('runs async event handlers attached via on() mixin', async () => {
let container = document.createElement('div')
let root = createRoot(container)
let calls = 0
root.render(
,
)
root.flush()
let button = container.querySelector('button')!
button.click()
// Let the async handler complete
await Promise.resolve()
await Promise.resolve()
expect(calls).toBe(1)
})
})
describe('queueTask errors', () => {
it('dispatches error event when sync queueTask throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let error = new Error('sync task error')
function Component(handle: Handle) {
handle.queueTask(() => {
throw error
})
return () => ok
}
root.render()
root.flush()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
it('dispatches error event when queueTask from update throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let error = new Error('update task error')
let update: () => void
function Component(handle: Handle) {
update = () => {
handle.queueTask(() => {
throw error
})
handle.update()
}
return () => ok
}
root.render()
root.flush()
expect(errorHandler).not.toHaveBeenCalled()
update!()
root.flush()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect((errorHandler.mock.calls[0][0] as ErrorEvent).error).toBe(error)
})
})
describe('error does not prevent other work', () => {
it('continues running tasks after task error', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let taskRan = false
function Bad(handle: Handle) {
handle.queueTask(() => {
throw new Error('bad task')
})
return () => bad
}
function Good(handle: Handle) {
handle.queueTask(() => {
taskRan = true
})
return () => good
}
root.render(
<>
>,
)
root.flush()
expect(errorHandler).toHaveBeenCalledTimes(1)
expect(taskRan).toBe(true)
})
})
describe('DOM state after errors', () => {
it('leaves DOM empty when initial render throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
root.addEventListener('error', () => {})
function Bad() {
throw new Error('bad')
return () => ok
}
root.render()
expect(container.innerHTML).toBe('')
})
it('preserves previous DOM when update throws', () => {
let container = document.createElement('div')
let root = createRoot(container)
root.addEventListener('error', () => {})
let shouldThrow = false
let update: () => void
function Component(handle: Handle) {
update = () => handle.update()
return () => {
if (shouldThrow) throw new Error('update error')
return ok
}
}
root.render()
expect(container.innerHTML).toBe('ok
')
shouldThrow = true
update!()
root.flush()
// Previous DOM is preserved
expect(container.innerHTML).toBe('ok
')
})
it('preserves DOM when event handler runs', () => {
let container = document.createElement('div')
let root = createRoot(container)
root.addEventListener('error', () => {})
root.render(
,
)
root.flush()
expect(container.innerHTML).toBe('')
let button = container.querySelector('button')!
button.click()
// DOM unchanged after event error
expect(container.innerHTML).toBe('')
})
})
describe('cascading updates protection', () => {
it('dispatches error when handle.update() is called during render', async () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let renderCount = 0
let triggerUpdate: () => void
function InfiniteLoop(handle: Handle) {
triggerUpdate = () => {
handle.update()
}
return () => {
renderCount++
if (renderCount > 1) {
handle.update()
}
return count: {renderCount}
}
}
root.render()
root.flush()
expect(container.innerHTML).toBe('count: 1
')
expect(renderCount).toBe(1)
triggerUpdate!()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(errorHandler).toHaveBeenCalled()
let error = (errorHandler.mock.calls[0][0] as ErrorEvent).error as Error
expect(error.message).toContain('infinite loop detected')
expect(renderCount).toBeLessThan(100)
})
it('allows legitimate multiple updates within same event loop turn', () => {
let container = document.createElement('div')
let root = createRoot(container)
let errorHandler = vi.fn()
root.addEventListener('error', errorHandler)
let count = 0
let update: () => void
function Counter(handle: Handle) {
update = () => handle.update()
return () => count: {count}
}
root.render()
root.flush()
count++
update!()
root.flush()
count++
update!()
root.flush()
count++
update!()
root.flush()
expect(container.innerHTML).toBe('count: 3
')
expect(errorHandler).not.toHaveBeenCalled()
})
})
})