import { describe, expect, it, vi } from 'vitest'
import { component, html, nextTick, onCleanup, pick, reactive, watch } from '..'
import type { Emit, Props } from '..'
const text = (node: Node) => node.textContent?.replace(/\s+/g, '') ?? ''
describe('component', () => {
it('renders reactive props without extra wrappers', async () => {
const data = reactive({ count: 1, other: 'value' })
const Counter = component((props: Props<{ count: number }>) =>
html`
${() => props.count}
`
)
const root = document.createElement('div')
html`${Counter(data)}`(root)
expect(root.innerHTML).toBe('1
')
data.count = 2
await nextTick()
expect(root.innerHTML).toBe('2
')
})
it('supports components with no props', () => {
const Static = component(() => html``)
const root = document.createElement('div')
html`${Static()}`(root)
expect(root.innerHTML).toBe('')
})
it('keeps local component state across higher-order rerenders', async () => {
const data = reactive({ count: 1, outer: true })
let created = 0
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({ id: ++created, clicks: 0 })
return html``
})
const root = document.createElement('div')
html`${() => (data.outer ? Child(data) : Child(data))}`(root)
let button = root.querySelector('button') as HTMLButtonElement
button.click()
await nextTick()
expect(button.textContent).toBe('1|1|1')
data.outer = false
await nextTick()
button = root.querySelector('button') as HTMLButtonElement
expect(button.textContent).toBe('1|1|1')
expect(created).toBe(1)
data.count = 2
await nextTick()
expect(button.textContent).toBe('2|1|1')
})
it('swaps prop sources without recreating the component instance', async () => {
const left = reactive({ count: 1 })
const right = reactive({ count: 5 })
const data = reactive({ left: true })
let created = 0
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({ id: ++created })
return html`${() => `${props.count}|${local.id}`}
`
})
const root = document.createElement('div')
html`${() => Child(data.left ? left : right)}`(root)
expect(root.textContent).toBe('1|1')
data.left = false
await nextTick()
expect(root.textContent).toBe('5|1')
expect(created).toBe(1)
right.count = 6
await nextTick()
expect(root.textContent).toBe('6|1')
})
it('creates separate instances for the same props object in separate slots', () => {
const data = reactive({ count: 1 })
let created = 0
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({ id: ++created })
return html`${() => `${props.count}|${local.id}`}
`
})
const root = document.createElement('div')
html`${[Child(data), Child(data)]}`(root)
expect(root.textContent).toBe('1|11|2')
expect(created).toBe(2)
})
it('preserves keyed child components when surrounding shape changes', async () => {
const data = reactive({ count: 1, wrapped: true })
let created = 0
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({ id: ++created })
return html``
})
const root = document.createElement('div')
html`
${() =>
data.wrapped
? [html`before`, Child(data).key('child')]
: [Child(data).key('child')]}
`(root)
expect(text(root)).toBe('before1|1')
data.wrapped = false
await nextTick()
expect(text(root)).toBe('1|1')
expect(created).toBe(1)
})
it('resets component state after the slot is removed', async () => {
const data = reactive({ count: 1, show: true })
let created = 0
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({ id: ++created })
return html`${() => `${props.count}|${local.id}`}
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child(data) : '')}`(root)
expect(root.textContent).toBe('1|1')
data.show = false
await nextTick()
expect(root.textContent).toBe('')
data.show = true
await nextTick()
expect(root.textContent).toBe('1|2')
})
it('replaces a component branch with a template branch', async () => {
const data = reactive({ personal: false })
const Child = component(() => html``)
const root = document.createElement('div')
html`${() => (data.personal ? html`Personal account
` : Child())}`(root)
expect(text(root)).toBe('Companyfield')
data.personal = true
await nextTick()
expect(text(root)).toBe('Personalaccount')
})
it('replaces a slotted component branch with a template branch', async () => {
const data = reactive({ personal: false })
const Card = component((props: Props<{ slots?: { default?: () => unknown } }>) =>
html`${() => props.slots?.default?.() ?? null}`
)
const Child = component(() => html``)
const root = document.createElement('div')
html`
${() =>
Card({
slots: {
default: () =>
data.personal ? html`Personal account
` : Child(),
},
})}
`(root)
expect(text(root)).toBe('Companyfield')
data.personal = true
await nextTick()
expect(text(root)).toBe('Personalaccount')
})
it('cleans up computed values created inside a component when the slot unmounts', async () => {
const data = reactive({ count: 1, show: true })
const runs = vi.fn((count: number) => count * 2)
const Child = component((props: Props<{ count: number }>) => {
const local = reactive({
total: reactive(() => runs(props.count)),
})
return html`${() => local.total}
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child(pick(data, 'count')) : '')}`(root)
expect(root.textContent).toBe('2')
expect(runs).toHaveBeenCalledTimes(1)
data.count = 2
await nextTick()
expect(root.textContent).toBe('4')
expect(runs).toHaveBeenCalledTimes(2)
data.show = false
await nextTick()
expect(root.textContent).toBe('')
data.count = 3
await nextTick()
expect(runs).toHaveBeenCalledTimes(2)
})
it('cleans up watchers created inside a component when the slot unmounts', async () => {
const data = reactive({ count: 1, show: true })
const runs = vi.fn()
const Child = component((props: Props<{ count: number }>) => {
watch(() => props.count, (value) => runs(value))
return html`${() => props.count}
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child(pick(data, 'count')) : '')}`(root)
expect(runs).toHaveBeenCalledTimes(1)
expect(runs).toHaveBeenLastCalledWith(1)
data.count = 2
await nextTick()
expect(runs).toHaveBeenCalledTimes(2)
expect(runs).toHaveBeenLastCalledWith(2)
data.show = false
await nextTick()
data.count = 3
await nextTick()
expect(runs).toHaveBeenCalledTimes(2)
})
it('runs onCleanup callbacks when a component unmounts', async () => {
const data = reactive({ show: true })
const cleanup = vi.fn()
const Child = component(() => {
onCleanup(cleanup)
return html`child
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child() : '')}`(root)
expect(cleanup).not.toHaveBeenCalled()
data.show = false
await nextTick()
expect(cleanup).toHaveBeenCalledTimes(1)
})
it('supports manual early disposal from onCleanup without double-running on unmount', async () => {
const data = reactive({ show: true })
const cleanup = vi.fn()
let dispose = () => {}
const Child = component(() => {
dispose = onCleanup(cleanup)
return html`child
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child() : '')}`(root)
dispose()
expect(cleanup).toHaveBeenCalledTimes(1)
data.show = false
await nextTick()
expect(cleanup).toHaveBeenCalledTimes(1)
})
it('supports onCleanup for manual event teardown on unmount', async () => {
const data = reactive({ show: true })
const handler = vi.fn()
const eventName = 'arrow-cleanup-test'
const Child = component(() => {
window.addEventListener(eventName, handler)
onCleanup(() => window.removeEventListener(eventName, handler))
return html`child
`
})
const root = document.createElement('div')
html`${() => (data.show ? Child() : '')}`(root)
window.dispatchEvent(new Event(eventName))
expect(handler).toHaveBeenCalledTimes(1)
data.show = false
await nextTick()
window.dispatchEvent(new Event(eventName))
expect(handler).toHaveBeenCalledTimes(1)
})
it('preserves keyed component state across list reorders', async () => {
const data = reactive({
items: [
reactive({ id: 1, label: 'one' }),
reactive({ id: 2, label: 'two' }),
],
})
let created = 0
const Child = component((props: Props<{ label: string }>) => {
const local = reactive({ id: ++created, clicks: 0 })
return html``
})
const root = document.createElement('div')
html`
${() => data.items.map((item) => Child(item).key(item.id))}
`(root)
const first = root.querySelector('button') as HTMLButtonElement
first.click()
await nextTick()
expect(text(root)).toBe('one|1|1two|2|0')
data.items.reverse()
await nextTick()
expect(text(root)).toBe('two|2|0one|1|1')
})
it('uses position rather than identity for non-keyed lists', async () => {
const data = reactive({
items: [
reactive({ id: 1, label: 'one' }),
reactive({ id: 2, label: 'two' }),
],
})
let created = 0
const Child = component((props: Props<{ label: string }>) => {
const local = reactive({ id: ++created })
return html`${() => `${props.label}|${local.id}`}
`
})
const root = document.createElement('div')
html`${() => data.items.map((item) => Child(item))}`(root)
expect(root.textContent).toBe('one|1two|2')
data.items.reverse()
await nextTick()
expect(root.textContent).toBe('two|1one|2')
})
it('keeps narrowed props live without a call-site closure', async () => {
const data = reactive({ count: 1, other: 'value' })
const Child = component((props: Props<{ count: number }>) =>
html`${() => props.count}
`
)
const root = document.createElement('div')
html`${Child(pick(data, 'count'))}`(root)
expect(root.textContent).toBe('1')
data.other = 'changed'
await nextTick()
expect(root.textContent).toBe('1')
data.count = 2
await nextTick()
expect(root.textContent).toBe('2')
})
it('forwards top-level prop writes to the source object', async () => {
const data = reactive({ count: 1 })
const Child = component((props: Props<{ count: number }>) =>
html``
)
const root = document.createElement('div')
html`${Child(data)}`(root)
const button = root.querySelector('button') as HTMLButtonElement
button.click()
await nextTick()
expect(data.count).toBe(2)
expect(button.textContent).toBe('2')
})
it('forwards top-level writes for narrowed props created with pick()', async () => {
const data = reactive({ count: 1, other: 'value' })
const Child = component((props: Props<{ count: number }>) =>
html``
)
const root = document.createElement('div')
html`${Child(pick(data, 'count'))}`(root)
const button = root.querySelector('button') as HTMLButtonElement
button.click()
await nextTick()
expect(data.count).toBe(2)
expect(button.textContent).toBe('2')
expect(data.other).toBe('value')
})
it('emits payloads to parent listeners without recreating child state', async () => {
const data = reactive({ count: 1, second: false })
const first = vi.fn()
const second = vi.fn()
let created = 0
const Child = component(
(
props: Props<{ count: number }>,
emit: Emit<{ color: string }>
) => {
const local = reactive({ id: ++created })
return html``
}
)
const root = document.createElement('div')
html`
${() =>
Child(data, {
color: data.second ? second : first,
})}
`(root)
let button = root.querySelector('button') as HTMLButtonElement
button.click()
expect(first).toHaveBeenCalledWith('1|1')
expect(created).toBe(1)
data.second = true
await nextTick()
button = root.querySelector('button') as HTMLButtonElement
button.click()
expect(second).toHaveBeenCalledWith('1|1')
expect(created).toBe(1)
})
it('supports emits for components without props', () => {
const ready = vi.fn()
const Child = component(
(_props: undefined, emit: Emit<{ ready: string }>) =>
html``
)
const root = document.createElement('div')
html`${Child(undefined, { ready })}`(root)
const button = root.querySelector('button') as HTMLButtonElement
button.click()
expect(ready).toHaveBeenCalledWith('ok')
})
it('enumerates proxied component props from the source object', () => {
const root = document.createElement('div')
let keys: string[] = []
let hasCount = false
const Child = component((props: Props<{ count: number; label: string }>) => {
keys = Object.keys(props as Record)
hasCount = 'count' in props
return html`${() => props.label}
`
})
html`${Child({ count: 2, label: 'ready' })}`(root)
expect(keys).toEqual(['count', 'label'])
expect(hasCount).toBe(true)
expect(root.innerHTML).toBe('ready
')
})
})