import { describe, it, expect } from 'vitest'
import type { Handle } from '../lib/component.ts'
import { createRangeRoot } from '../lib/vdom.ts'
import { invariant } from '../lib/invariant.ts'
import { on } from '../index.ts'
describe('createRangeRoot', () => {
describe('event forwarding', () => {
it('forwards bubbling DOM error events to range root listeners', () => {
let host = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
host.append(start, end)
let root = createRangeRoot([start, end])
let forwarded: unknown
root.addEventListener('error', (event) => {
forwarded = (event as ErrorEvent).error
})
let expected = new Error('createRangeRoot forwarded error')
host.dispatchEvent(new ErrorEvent('error', { bubbles: true, error: expected }))
expect(forwarded).toBe(expected)
})
it('stops forwarding bubbling DOM error events after dispose', () => {
let host = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
host.append(start, end)
let root = createRangeRoot([start, end])
let forwarded: unknown
root.addEventListener('error', (event) => {
forwarded = (event as ErrorEvent).error
})
root.dispose()
host.dispatchEvent(
new ErrorEvent('error', { bubbles: true, error: new Error('after dispose') }),
)
expect(forwarded).toBeUndefined()
})
})
describe('basic rendering', () => {
it('dispose is a no-op before first render', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.dispose()
root.flush()
expect(container.innerHTML).toBe('')
})
it('renders content between markers', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
Hello
)
root.flush()
expect(container.innerHTML).toBe('
Hello
')
})
it('renders text between markers', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render('Hello, world!')
root.flush()
expect(container.innerHTML).toBe('Hello, world!')
})
it('renders fragments between markers', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
<>
')
})
it('handles removing children', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
To remove
,
)
root.flush()
root.render()
root.flush()
expect(container.innerHTML).toBe('')
})
it('replaces all fragment children with different elements', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
<>
')
// Replace divs with spans
root.render(
<>
AB
>,
)
root.flush()
expect(container.innerHTML).toBe('AB')
})
it('renders null then content', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(null)
root.flush()
expect(container.innerHTML).toBe('')
root.render(
Now visible
)
root.flush()
expect(container.innerHTML).toBe('
Now visible
')
})
it('renders content then null then content', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
Visible
)
root.flush()
expect(container.innerHTML).toBe('
Visible
')
root.render(null)
root.flush()
expect(container.innerHTML).toBe('')
root.render(Back again)
root.flush()
expect(container.innerHTML).toBe('Back again')
})
it('changes fragment child count', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let root = createRangeRoot([start, end])
root.render(
<>
')
// Back to multiple
root.render(
<>
AB
>,
)
root.flush()
expect(container.innerHTML).toBe('AB')
})
})
describe('events', () => {
it('attaches event handlers', () => {
let container = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
container.appendChild(start)
container.appendChild(end)
let clicked = false
let root = createRangeRoot([start, end])
root.render(
,
)
root.flush()
let button = container.querySelector('button')
invariant(button)
button.click()
expect(clicked).toBe(true)
})
})
describe('multiple range roots', () => {
it('supports multiple ranges in same container', () => {
let container = document.createElement('div')
container.innerHTML =
'
A
static
B
'
let startA = container.childNodes[0] as Comment
let endA = container.childNodes[2] as Comment
let startB = container.childNodes[4] as Comment
let endB = container.childNodes[6] as Comment
let existingDivA = container.querySelector('div')
let existingDivB = container.querySelectorAll('div')[1]
let rootA = createRangeRoot([startA, endA])
let rootB = createRangeRoot([startB, endB])
rootA.render(
',
)
// Original nodes reused
expect(container.querySelector('div')).toBe(existingDivA)
expect(container.querySelectorAll('div')[1]).toBe(existingDivB)
})
it('ranges are independent', () => {
let container = document.createElement('div')
let startA = document.createComment('a')
let endA = document.createComment('/a')
let startB = document.createComment('b')
let endB = document.createComment('/b')
container.appendChild(startA)
container.appendChild(endA)
container.appendChild(startB)
container.appendChild(endB)
let rootA = createRangeRoot([startA, endA])
let rootB = createRangeRoot([startB, endB])
rootA.render(A)
rootA.flush()
// Only A has content, B is empty
expect(container.innerHTML).toBe('A')
rootB.render(B)
rootB.flush()
expect(container.innerHTML).toBe(
'AB',
)
})
})
describe('boundary handling', () => {
it('throws when start and end markers do not share a parent node', () => {
let containerA = document.createElement('div')
let containerB = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
containerA.appendChild(start)
containerB.appendChild(end)
expect(() => createRangeRoot([start, end])).toThrow('Boundaries must share parent')
})
it('does not affect content before start marker', () => {
let container = document.createElement('div')
container.innerHTML = 'Before'
let start = container.childNodes[1] as Comment
let end = container.childNodes[2] as Comment
let root = createRangeRoot([start, end])
root.render(
',
)
})
it('does not affect content after end marker', () => {
let container = document.createElement('div')
// Range has existing content to hydrate
container.innerHTML = '
Old
'
let start = container.childNodes[0] as Comment
let end = container.childNodes[2] as Comment
let root = createRangeRoot([start, end])
root.render(
New
)
root.flush()
expect(container.innerHTML).toBe('
New
')
})
it('handles empty ranges with content after end marker', () => {
let container = document.createElement('div')
// Empty range with content following it
container.innerHTML = ''
let start = container.firstChild as Comment
let end = container.childNodes[1] as Comment
let footer = container.querySelector('footer')
invariant(footer)
let root = createRangeRoot([start, end])
root.render(
New content
)
root.flush()
// Content should be inserted inside the range, not adopt the footer
expect(container.innerHTML).toBe(
'
New content
',
)
// Footer should be unchanged
expect(container.querySelector('footer')).toBe(footer)
})
it('preserves surrounding content during updates', () => {
let container = document.createElement('div')
container.innerHTML = 'H
Old
'
let start = container.childNodes[1] as Comment
let end = container.childNodes[3] as Comment
let root = createRangeRoot([start, end])
root.render(