import { html, svg, reactive, nextTick, ArrowTemplate } from '..'
import { click, setValue } from './utils/events'
import { describe, it, expect, vi } from 'vitest'
import {
createHydrationCapture,
installHydrationCaptureProvider,
} from '../internal'
interface User {
name: string
id: number
}
describe('hydration capture', () => {
it('records adoption hooks only when a capture provider is active', () => {
const template = html` {}}">probe `
const inactiveRoot = document.createElement('div')
template(inactiveRoot)
const capture = createHydrationCapture()
installHydrationCaptureProvider(() => capture)
try {
const activeTemplate = html` {}}">probe `
const activeRoot = document.createElement('div')
activeTemplate(activeRoot)
expect(capture.hooks.get(template._c())).toBeUndefined()
expect(capture.hooks.get(activeTemplate._c())?.length).toBeGreaterThan(0)
} finally {
installHydrationCaptureProvider(null)
}
})
})
// describe('createHTML', () => {
// it('ignores empty templates', () => {
// const html = createHTML([''])
// expect(html).toBe('')
// })
// it('adds a delimiter even when there is no html', () => {
// const html = createHTML(['', ''])
// expect(html).toBe('')
// })
// it('adds multiple delimiters even when there is no html', () => {
// const html = createHTML(['', '', '', ''])
// expect(html).toBe('')
// })
// it('can place a delimiter comment inside an element', () => {
// const html = createHTML(['
', '
'])
// expect(html).toBe(`
`)
// })
// it('can place a delimiter comment after a self closing element', () => {
// const html = createHTML([' ', ''])
// expect(html).toBe(` `)
// })
// it('can place an attr delimiter comment after a self closing element', () => {
// const html = createHTML([' ', ''])
// expect(html).toBe(` `)
// })
// })
// describe('attrCommentPos', () => {
// it('can find the position of an attribute comment', () => {
// // prettier-ignore
// const left = [" ']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(0)
// expect(posIndex).toBe(16)
// })
// it('can find the position of an attribute comment in the second index of the right hand stack', () => {
// // prettier-ignore
// const left = [" ']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(1)
// expect(posIndex).toBe(2)
// })
// it('can find the position of an attribute comment in the second index of the right hand stack', () => {
// // prettier-ignore
// const left = [" things here']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(1)
// expect(posIndex).toBe(2)
// })
// it('can find the position of an attribute comment in the second index of the right hand stack', () => {
// // prettier-ignore
// const left = ["<input type="]
// const right = [' data-foo="', '">']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(null)
// expect(posIndex).toBe(null)
// })
// it('does not find a position if the opening < is inside quotes', () => {
// // prettier-ignore
// const left = ['<input type="']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(null)
// expect(posIndex).toBe(null)
// })
// it('can find the correct position if the opening < is in the middle of some html', () => {
// // prettier-ignore
// const left = ["
Hello
', ' some stuff in here
']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(1)
// expect(posIndex).toBe(2)
// })
// it('can find the correct position attributes contain < >and escaped quotes', () => {
// // prettier-ignore
// const left = ["
Hello
\" data-foo=\""]
// // prettier-ignore
// const right = ['" data-bar="', "\" data-post-html=\"\">
', ' some stuff in here
"]
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(1)
// expect(posIndex).toBe(41)
// })
// it('can immediately break out when finding a < outside quotes in the front stack', () => {
// // prettier-ignore
// const left = ["
']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(null)
// expect(posIndex).toBe(null)
// })
// it('can immediately break out when finding a < outside quotes in the front stack', () => {
// // prettier-ignore
// const left = [" type="]
// const right = ['" data-foo="bar">']
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(null)
// expect(posIndex).toBe(null)
// })
// it('returns null when attributes contain < > and escaped quotes but the ending quote is never found', () => {
// // prettier-ignore
// const left = ["Hello
' data-foo=\""]
// // prettier-ignore
// const right = ['" data-bar="', "\" data-post-html=\"'>
', ' some stuff in here
"]
// const [stackIndex, posIndex] = attrCommentPos(left, right)
// expect(stackIndex).toBe(null)
// expect(posIndex).toBe(null)
// })
// })
describe('html', () => {
it('can render simple strings', () => {
const nodes = html`foo bar`().childNodes
expect(nodes.length).toBe(1)
expect(nodes[0].nodeName).toBe('#text')
expect(nodes[0].nodeValue).toBe('foo bar')
})
it('can render simple numeric expressions', () => {
const nodes = html`${10 * 10}`().childNodes
expect(nodes.length).toBe(1)
expect(nodes[0].nodeName).toBe('#text')
expect(nodes[0].nodeValue).toBe('100')
})
it('does not render falsy expressions', () => {
const parent = document.createElement('div')
html`${false}-${null}-${undefined}-${0}-${NaN}`(parent)
expect(parent.innerHTML).toBe('---0-')
})
it('does not render falsy expression returns', () => {
const parent = document.createElement('div')
html`${() => false}-${() => null}-${() => undefined}-${() => 0}-${() =>
NaN}`(parent)
expect(parent.innerHTML).toBe('---0-')
})
it('can render simple text with expressions', async () => {
const world = 'World'
const fragment = html`Hello ${world}`()
const nodes = fragment.childNodes
await nextTick()
expect(nodes.length).toBe(2)
expect(nodes[0].nodeName).toBe('#text')
expect(fragment.textContent).toBe('Hello World')
})
it('can render reactive data once without arrow fn', async () => {
const data = reactive({ name: 'World' })
const node = html`Hello ${data.name}`()
expect(node.childNodes.length).toBe(2)
expect(node.textContent).toBe('Hello World')
data.name = 'Justin'
await nextTick()
expect(node.textContent).toBe('Hello World')
})
it('can render reactive data once without arrow fn at depth', async () => {
const data = reactive({ name: 'world' })
const parent = document.createElement('div')
html`
Hello ${data.name} `(parent)
expect(parent.innerHTML).toBe('
Hello world ')
data.name = 'Justin'
await nextTick()
expect(parent.innerHTML).toBe('
Hello world ')
})
it('can render static expression in an attribute', async () => {
const data = reactive({ name: 'world' })
const parent = document.createElement('div')
html`
Hello ${data.name}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.name = 'Justin'
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('throws a clear error when an expression is placed inside a tag opening', () => {
expect(() => html`
'broken'}> `()).toThrow(
/invalid HTML position/i
)
})
it('supports generated textarea bindings from string arrays', async () => {
const data = reactive({ value: 'Arrow textarea' })
const root = document.createElement('div')
const strings = ['']
html(strings, () => data.value, () => data.value)(root)
const textarea = root.querySelector('textarea') as HTMLTextAreaElement
expect(textarea).toBeTruthy()
expect(textarea.value).toBe('Arrow textarea')
data.value = 'Updated textarea'
await nextTick()
expect(textarea.value).toBe('Updated textarea')
})
it('automatically updates expressions with arrow fn', async () => {
const data = reactive({ name: 'World' })
const parent = document.createElement('div')
html`Hello ${() => data.name}`(parent)
expect(parent.textContent).toBe('Hello World')
data.name = 'Justin'
await nextTick()
expect(parent.textContent).toBe('Hello Justin')
})
it('can create a token expression at the beginning of template', async () => {
const data = reactive({ name: 'Hello' })
const parent = document.createElement('div')
html`${() => data.name} Worldilocks`(parent)
expect(parent.textContent).toBe('Hello Worldilocks')
data.name = 'Justin'
await nextTick()
expect(parent.textContent).toBe('Justin Worldilocks')
})
it('can place expression nested inside some elements inside a string', async () => {
const data = reactive({ name: 'Hello' })
const parent = document.createElement('div')
html`This is cool
And here is more text
Name: ${() => data.name} ok?
${data.name} `(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.name = 'Justin'
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('can sub-render templates without reactivity.', async () => {
const data = reactive({ name: 'World' })
const parent = document.createElement('div')
html`Hello ${html`${data.name}
`}`(parent)
expect(parent.innerHTML).toBe('Hello World
')
data.name = 'Justin'
await nextTick()
expect(parent.innerHTML).toBe('Hello World
')
})
it('upgrades reactive text bindings to structured renderables and back', async () => {
const data = reactive({ active: false })
const parent = document.createElement('div')
html`before ${() =>
data.active ? html`after ` : 'text'}end
`(
parent
)
const before = parent.querySelector('span')
const end = parent.querySelector('em')
expect(parent.innerHTML).toBe(
'before textend
'
)
data.active = true
await nextTick()
expect(parent.innerHTML).toBe(
'before after end
'
)
expect(parent.querySelector('span')).toBe(before)
expect(parent.querySelector('em')).toBe(end)
data.active = false
await nextTick()
expect(parent.innerHTML).toBe(
'before textend
'
)
expect(parent.querySelector('span')).toBe(before)
expect(parent.querySelector('em')).toBe(end)
})
it('can render a simple non-reactive list', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`Hello
${data.list.map((item: string) => html`${item} `)}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.list[1] = 'Justin'
await nextTick()
// We shouldn't see any changes because that list was non-reactive.
expect(parent.innerHTML).toMatchSnapshot()
})
it('can render a simple reactive list that pushes a new reactive value on', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`Hello
${() => data.list.map((item: string) => html`${() => item} `)}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.list.push('next')
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('can render a simple reactive list that unshifts a new reactive value on', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`Hello
${() => data.list.map((item: string) => html`${() => item} `)}
`(parent)
const firstListItem = parent.querySelector('li')
data.list.unshift('0')
await nextTick()
const listValues: string[] = []
parent
.querySelectorAll('li')
.forEach((el) => listValues.push(el.textContent!))
expect(listValues).toEqual(['0', 'a', 'b', 'c'])
expect(parent.querySelector('li')).toBe(firstListItem)
})
it('re-renders a simple list that changes a static value', async () => {
const data = reactive({
list: [{ value: 'a' }, { value: 'b' }, { value: 'c' }],
})
const parent = document.createElement('div')
html`Hello
${() => data.list.map((item) => html`${item.value} `)}
`(parent)
data.list[1].value = 'foo'
await nextTick()
const listValues: string[] = []
parent
.querySelectorAll('li')
.forEach((el) => listValues.push(el.textContent!))
expect(listValues).toEqual(['a', 'foo', 'c'])
})
it('reuses non-keyed nodes when a same-length list updates static values', async () => {
const data = reactive({
list: [{ value: 'a' }, { value: 'b' }, { value: 'c' }],
})
const parent = document.createElement('div')
html`
${() => data.list.map((item) => html`${item.value} `)}
`(parent)
const before = [...parent.querySelectorAll('li')]
data.list[1].value = 'next'
await nextTick()
const after = [...parent.querySelectorAll('li')]
expect(after).toHaveLength(3)
expect(after[0]).toBe(before[0])
expect(after[1]).toBe(before[1])
expect(after[2]).toBe(before[2])
expect(after[1]?.textContent).toBe('next')
})
it('appends non-keyed rows without remounting the preserved prefix', async () => {
const data = reactive({
list: [{ value: 'a' }, { value: 'b' }, { value: 'c' }],
})
const parent = document.createElement('div')
html`
${() => data.list.map((item) => html`${item.value} `)}
`(parent)
const list = parent.querySelector('ul') as HTMLUListElement
const insertBefore = vi.spyOn(list, 'insertBefore')
const before = [...parent.querySelectorAll('li')]
data.list.push({ value: 'd' }, { value: 'e' })
await nextTick()
const after = [...parent.querySelectorAll('li')]
expect(insertBefore.mock.calls.length).toBeLessThanOrEqual(1)
expect(after[0]).toBe(before[0])
expect(after[1]).toBe(before[1])
expect(after[2]).toBe(before[2])
expect(after.map((item) => item.textContent)).toEqual(['a', 'b', 'c', 'd', 'e'])
})
it('can render an empty list, render some items, remove the items, and render some again', async () => {
const data = reactive<{ list: string[] }>({ list: [] })
const parent = document.createElement('div')
html`
${() => data.list.map((item: string) => html`${() => item} `)}
`(parent)
expect(parent.querySelector('ul')?.innerHTML).toMatchSnapshot()
data.list.push('a')
await nextTick()
expect(parent.querySelector('ul')?.innerHTML).toMatchSnapshot()
data.list.shift()
await nextTick()
expect(parent.querySelector('ul')?.innerHTML).toMatchSnapshot()
data.list.push('c')
await nextTick()
expect(parent.querySelector('ul')?.innerHTML).toMatchSnapshot()
})
it('can render a simple reactive list that shifts a static value off', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`Hello
${() => data.list.map((item: string) => html`${item} `)}
`(parent)
// expect(parent.innerHTML).toMatchSnapshot()
expect(parent.querySelectorAll('li').length).toBe(3)
data.list.shift()
await nextTick()
// expect(parent.querySelectorAll('li').length).toBe(2)
const listValues: string[] = []
parent
.querySelectorAll('li')
.forEach((el) => listValues.push(el.textContent!))
expect(listValues).toEqual(['b', 'c'])
})
it('can render a list with different templates', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`${() =>
data.list.map((item: string) => {
if (item === 'a') return html`${item} `
if (item === 'b') return html`${item} `
if (item === 'c') return html`${item} `
return html`${item} `
})}`(parent)
expect(parent.innerHTML).toBe('a b c ')
data.list.shift()
await nextTick()
expect(parent.innerHTML).toBe('b c ')
})
it('can render a list with multiple repeated roots.', () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`
${() =>
data.list.map(
(item: string) =>
html`
${item}
foobar
`
)}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
})
it('can render a list with new values un-shifted on', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`
${() => data.list.map((item: string) => html`${item} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list.unshift('z', 'x')
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can render a list with new values pushed', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`
${() => data.list.map((item: string) => html`${item} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list.push('z', 'x')
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can render a list with new values spliced in', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`
${() => data.list.map((item: string) => html`${item} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list.splice(1, 2, 'z', 'y', 'x', 'l')
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can render a list with new values spliced in', async () => {
const data = reactive({ list: ['a', 'b', 'c'] })
const parent = document.createElement('div')
html`
${() => data.list.map((item: string) => html`${item} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list.splice(1, 2, 'z', 'y', 'x', 'l')
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can render a list with a for loop', async () => {
const data = reactive({ list: ['a', 'b', 'c'] as string[] })
const parent = document.createElement('div')
function list(items: string[]): ArrowTemplate[] {
const els: ArrowTemplate[] = []
for (const i in items) {
els.push(html`${items[i]} `)
}
return els
}
html``(parent)
expect(parent.innerHTML).toBe(``)
data.list.push('item')
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can remove items from a mapped list by splicing', async () => {
const data = reactive({
list: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
})
const parent = document.createElement('div')
html`
${() => data.list.map((item) => html`${() => item.name} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list.splice(0, 1)
await nextTick()
expect(parent.innerHTML).toBe(``)
data.list.splice(0, 1)
await nextTick()
expect(parent.innerHTML).toBe(``)
data.list.splice(0, 1)
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('preserves event handlers for non-keyed templates shifted by splicing', async () => {
const clicked: number[] = []
const data = reactive({
list: Array.from({ length: 15 }, (_, i) =>
html`
${i + 1}
clicked.push(i + 1)}">remove
`
),
})
const parent = document.createElement('div')
html``(parent)
data.list.splice(8, 1)
await nextTick()
data.list.splice(7, 1)
await nextTick()
data.list.splice(6, 1)
await nextTick()
data.list.splice(5, 1)
await nextTick()
data.list.splice(4, 1)
await nextTick()
const rows = parent.querySelectorAll('tbody tr')
expect(rows[5]?.firstElementChild?.textContent).toBe('11')
click(rows[5]?.querySelector('button') as HTMLButtonElement)
expect(clicked).toEqual([11])
})
it('can render a list from an object', async () => {
const data = reactive<{ food: Record }>({
food: {
main: 'Pizza',
desert: 'ice cream',
},
})
const parent = document.createElement('div')
function list(items: Record): ArrowTemplate[] {
const els: ArrowTemplate[] = []
for (const i in items) {
els.push(html`${i}: ${items[i]} `)
}
return els
}
html``(parent)
expect(parent.innerHTML).toBe(`
main: Pizza desert: ice cream
`)
data.food.breakfast = 'bacon'
await nextTick()
expect(parent.innerHTML).toBe(`
main: Pizza desert: ice cream breakfast: bacon
`)
})
it('re-uses nodes that had sub value change.', async () => {
const data = reactive({
list: [
{ name: 'Justin', id: 3 },
{ name: 'Luan', id: 1 },
{ name: 'Andrew', id: 2 },
],
})
const parent = document.createElement('div')
html`
${() => data.list.map((user: User) => html`${() => user.name} `)}
`(parent)
expect(parent.innerHTML).toBe(``)
const first = parent.querySelector('li')
data.list[0].name = 'Bob'
await nextTick()
expect(first).toBe(parent.querySelector('li'))
expect(parent.innerHTML).toBe(``)
})
it('can move keyed nodes in a list', async () => {
const data = reactive({
list: [
{ name: 'Justin', id: 3 },
{ name: 'Luan', id: 0 },
{ name: 'Andrew', id: 2 },
] as Array<{ name: string; id: number }>,
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((user: User) =>
html`${() => user.name} `.key(user.id)
)}
`(parent)
expect(parent.innerHTML).toBe(``)
// Manually apply some "state" to the DOM
parent.querySelector('li')?.setAttribute('data-is-justin', 'true')
data.list.splice(0, 1)
data.list.push(
reactive({ name: 'Justin', id: 3 }),
reactive({ name: 'Fred', id: 1 })
)
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can sort keyed nodes in a list', async () => {
const data = reactive({
list: [
{ name: 'Justin', id: 3 },
{ name: 'Luan', id: 1 },
{ name: 'Andrew', id: 2 },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((user: User) =>
html`${() => user.name} `.key(user.id)
)}
`(parent)
parent.querySelector('li')?.setAttribute('data-is-justin', 'true')
parent
.querySelector('li:nth-child(2)')
?.setAttribute('data-is-luan', 'true')
parent
.querySelector('li:nth-child(3)')
?.setAttribute('data-is-andrew', 'true')
data.list.sort((a: User, b: User) => {
return a.name > b.name ? 1 : -1
})
// await nextTick()
// expect(parent.innerHTML).toBe(``)
})
it('can swap keyed nodes without losing order', async () => {
const data = reactive({
list: [
{ name: 'a', id: 1 },
{ name: 'b', id: 2 },
{ name: 'c', id: 3 },
{ name: 'd', id: 4 },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((item: User) => html`${item.name} `.key(item.id))}
`(parent)
const before = [...parent.querySelectorAll('li')]
data.list = [data.list[0], data.list[2], data.list[1], data.list[3]]
await nextTick()
const after = [...parent.querySelectorAll('li')]
expect(after.map((item) => item.textContent)).toEqual(['a', 'c', 'b', 'd'])
expect(after[1]).toBe(before[2])
expect(after[2]).toBe(before[1])
})
it('only moves the swapped keyed rows for distant swaps', async () => {
const data = reactive({
list: Array.from({ length: 1000 }, (_, id) => ({ id, name: `${id}` })),
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((item: User) => html`${item.name} `.key(item.id))}
`(parent)
const list = parent.querySelector('ul') as HTMLUListElement
const insertBefore = vi.spyOn(list, 'insertBefore')
const swapped = data.list.slice()
const item = swapped[1]
swapped[1] = swapped[998]
swapped[998] = item
data.list = swapped
await nextTick()
expect(insertBefore).toHaveBeenCalledTimes(2)
})
it('appends keyed rows without moving the preserved prefix', async () => {
const data = reactive({
list: [
{ name: 'a', id: 1 },
{ name: 'b', id: 2 },
{ name: 'c', id: 3 },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((item: User) => html`${item.name} `.key(item.id))}
`(parent)
const list = parent.querySelector('ul') as HTMLUListElement
const insertBefore = vi.spyOn(list, 'insertBefore')
const before = [...parent.querySelectorAll('li')]
data.list.push({ name: 'd', id: 4 }, { name: 'e', id: 5 })
await nextTick()
const after = [...parent.querySelectorAll('li')]
expect(insertBefore.mock.calls.length).toBeLessThanOrEqual(1)
expect(after[0]).toBe(before[0])
expect(after[1]).toBe(before[1])
expect(after[2]).toBe(before[2])
expect(after.map((item) => item.textContent)).toEqual(['a', 'b', 'c', 'd', 'e'])
})
it('replaces keyed rows when the next list has no overlapping keys', async () => {
const data = reactive({
list: [
{ name: 'a', id: 1 },
{ name: 'b', id: 2 },
{ name: 'c', id: 3 },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((item: User) => html`${item.name} `.key(item.id))}
`(parent)
const before = [...parent.querySelectorAll('li')]
data.list = [
{ name: 'd', id: 4 },
{ name: 'e', id: 5 },
{ name: 'f', id: 6 },
]
await nextTick()
const after = [...parent.querySelectorAll('li')]
expect(after.map((item) => item.textContent)).toEqual(['d', 'e', 'f'])
expect(after.some((item) => before.includes(item))).toBe(false)
})
it('can update the values in keyed nodes', async () => {
const data = reactive({
list: [
{ name: 'Justin', id: 3 },
{ name: 'Luan', id: 1 },
{ name: 'Andrew', id: 2 },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((user: User) => {
return html`${() => user.name} `.key(user.id)
})}
`(parent)
data.list[0].name = 'Bob'
data.list[1] = { name: 'Jeff', id: 1 }
data.list[2].name = 'Fred'
await nextTick()
expect(parent.innerHTML).toBe(``)
data.list[2].name = 'Ted'
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('updates keyed templates when the same key changes template shape', async () => {
const data = reactive({
list: [
{ id: 1, label: 'Alpha', active: false },
{ id: 2, label: 'Beta', active: false },
],
})
const parent = document.createElement('div')
html`
${() =>
data.list.map((item) =>
(item.active
? html`${item.label} `
: html`${item.label} `).key(item.id)
)}
`(parent)
expect(parent.innerHTML).toBe(``)
data.list[0].active = true
await nextTick()
expect(parent.innerHTML).toBe(``)
data.list[0].active = false
data.list[1].active = true
await nextTick()
expect(parent.innerHTML).toBe(``)
})
it('can render results of multiple data objects', async () => {
const a = reactive({ price: 45 })
const b = reactive({ quantity: 25 })
const parent = document.createElement('div')
html`${() => a.price * b.quantity}`(parent)
expect(parent.innerHTML).toBe('1125')
a.price = 100
await nextTick()
expect(parent.innerHTML).toBe('2500')
})
it('can conditionally swap nodes', async () => {
const data = reactive({
price: 100,
promo: 'free',
showPromo: false,
})
const parent = document.createElement('div')
const componentA = html`Price: ${() => data.price}`
const componentB = html`Promo: `
html`
${() => (data.showPromo ? componentB : componentA)}
`(parent)
expect(parent.innerHTML).toBe(`
Price: 100
`)
data.showPromo = true
await nextTick()
expect(parent.innerHTML).toBe(`
Promo:
`)
})
it('can conditionally show/remove nodes', async () => {
const data = reactive({
showPromo: false,
})
const parent = document.createElement('div')
// Note: this test seems obtuse but it isn't since it performing this toggle
// action multiple times stress tests the underlying placeholder mechanism.
const promo = html`Promo: `
html`${() => data.showPromo && promo}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.showPromo = true
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
data.showPromo = false
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
data.showPromo = true
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('outputs the boolean true, but not the boolean false', () => {
const parent = document.createElement('div')
expect(
(html`${() => true}${() => false}`(parent) as Element).innerHTML
).toBe('true')
})
it('can render an attribute', () => {
const parent = document.createElement('div')
const data = reactive({
org: 'braid',
})
expect(
(html`
`(parent) as Element)
.innerHTML
).toBe(`
`)
})
it('can remove and re-add multiple attributes', async () => {
const parent = document.createElement('div')
const data = reactive({
org: 'braid' as boolean | string,
precinct: false as boolean | string,
state: 'virginia',
})
html`
${() => data.state}
`(parent) as Element
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
data.precinct = 'cville'
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
data.org = false
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
data.org = 'other'
data.state = 'california'
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('can render nested nodes with attribute expressions', async () => {
const parent = document.createElement('div')
const data = reactive({
country: 'usa',
states: [
{ name: 'virginia', abbr: 'va' },
{ name: 'nebraska', abbr: 'ne' },
{ name: 'california', abbr: 'ca' },
] as Array<{ name: string; abbr: string }>,
})
html`
${() =>
data.states.map(
(state: { name: string; abbr: string }) =>
html`${() => state.name} `
)}
${() => data.states[0].name}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
data.states.sort((a, b) => {
return a.abbr > b.abbr ? 1 : -1
})
await nextTick()
expect(parent.innerHTML).toMatchSnapshot()
})
it('can render the number zero', async () => {
const parent = document.createElement('div')
html`${() => 0}|${0}`(parent)
expect(parent.innerHTML).toBe('0|0')
})
it('can bind to native events as easily as pecan pie', async () => {
const parent = document.createElement('div')
const data = reactive({ value: '' })
const update = (event: Event) => {
data.value = (event.target as HTMLInputElement).value
}
html` ${() => data.value}`(parent)
setValue(parent.querySelector('input'), 'pizza')
await nextTick()
expect(parent.innerHTML).toBe(' pizza')
})
it('preserves currentTarget for shared event handlers', () => {
const parent = document.createElement('div')
const handler = vi.fn((event: Event) => event.currentTarget)
html`probe `(parent)
const button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(handler).toHaveBeenCalledTimes(1)
expect(handler.mock.results[0]?.value).toBe(button)
})
it('honors stopPropagation with delegated bubbling handlers', () => {
const parent = document.createElement('div')
const outer = vi.fn()
const inner = vi.fn((event: Event) => event.stopPropagation())
html`probe
`(
parent
)
click(parent.querySelector('button') as HTMLButtonElement)
expect(inner).toHaveBeenCalledTimes(1)
expect(outer).toHaveBeenCalledTimes(0)
})
it('updates shared event handlers when a reused node changes templates', async () => {
const parent = document.createElement('div')
const first = vi.fn()
const second = vi.fn()
const data = reactive({ active: 'first' as 'first' | 'second' })
html`${() =>
data.active === 'first'
? html`probe `
: html`probe `}`(parent)
const button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(0)
data.active = 'second'
await nextTick()
expect(parent.querySelector('button')).toBe(button)
click(button)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(1)
})
it('supports compiler-generated string arrays with attrs, events, and signature reuse', async () => {
const parent = document.createElement('div')
const first = vi.fn()
const second = vi.fn()
const data = reactive({
title: 'ready',
label: 'Push',
active: 'first' as 'first' | 'second',
})
const button = () =>
html(
['', ' '],
data.title,
data.active === 'first' ? first : second,
data.label
)
html`${() => button()}`(parent)
const target = parent.querySelector('button') as HTMLButtonElement
expect(target.getAttribute('title')).toBe('ready')
expect(target.textContent).toBe('Push')
click(target)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(0)
data.title = 'clicked'
data.label = 'Again'
data.active = 'second'
await nextTick()
expect(parent.querySelector('button')).toBe(target)
expect(target.getAttribute('title')).toBe('clicked')
expect(target.textContent).toBe('Again')
click(target)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(1)
})
it('sets the IDL value attribute on input elements', async () => {
const parent = document.createElement('div')
const data = reactive({ value: '' })
const update = (event: InputEvent) => {
data.value = (event.target as HTMLInputElement).value
}
html`
${() => data.value}`(parent)
const a = parent.querySelector('[id="a"]') as HTMLInputElement
const b = parent.querySelector('[id="b"]') as HTMLInputElement
setValue(a, 'pizza')
await nextTick()
expect(b.value).toBe('pizza')
setValue(b, 'pie')
await nextTick()
expect(a.value).toBe('pie')
expect(a.getAttribute('value')).toBe(null)
})
it('sets the IDL checked attribute on checkbox elements', async () => {
const parent = document.createElement('div')
const data = reactive({ checked: false })
html` `(
parent
)
const a = parent.querySelector('[id="a"]') as HTMLInputElement
expect(a.checked).toBe(false)
a.checked = true
await nextTick()
expect(a.checked).toBe(true)
expect(data.checked).toBe(false)
data.checked = true
await nextTick()
expect(a.checked).toBe(true)
data.checked = false
await nextTick()
expect(a.checked).toBe(false)
expect(a.getAttribute('checked')).toBe(null)
})
it('cleans up event listeners when a node has been removed', async () => {
const clickHandler = vi.fn()
const parent = document.createElement('div')
const data = reactive({
show: true,
})
html`${() =>
data.show ? html` ` : ''}`(
parent
)
let button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(clickHandler).toHaveBeenCalledTimes(1)
data.show = false
await nextTick()
click(button)
expect(clickHandler).toHaveBeenCalledTimes(1)
data.show = true
await nextTick()
button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(clickHandler).toHaveBeenCalledTimes(2)
})
it('removes deeply nested event listeners', async () => {
const clickHandler = vi.fn()
const parent = document.createElement('div')
const data = reactive({
show: true,
})
html`${() =>
data.show
? html`
`
: ''}`(parent)
let button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(clickHandler).toHaveBeenCalledTimes(1)
data.show = false
await nextTick()
click(button)
expect(clickHandler).toHaveBeenCalledTimes(1)
data.show = true
await nextTick()
button = parent.querySelector('button') as HTMLButtonElement
click(button)
expect(clickHandler).toHaveBeenCalledTimes(2)
})
it('defaults to the proper option select element', () => {
const parent = document.createElement('div')
const data = reactive({ selected: 'b' })
html`
A
B
C
`(parent)
expect(parent.querySelector('select')?.value).toBe('b')
})
it('can create a table with dynamic columns and rows', () => {
const parent = document.createElement('div')
const rows = [
['Detroit', 'MI'],
['Boston', 'MA'],
]
html`
${rows.map(
(row) =>
html`
${row.map((column) => html`${column} `)}
`
)}
`(parent)
expect(parent.innerHTML).toMatchSnapshot()
})
it('renders nested svg templates in the svg namespace', () => {
const parent = document.createElement('div')
const values = [40, 20]
html`
${values.map(
(value, index) => svg` `
)}
`(parent)
const rects = Array.from(parent.querySelectorAll('rect'))
expect(rects).toHaveLength(2)
expect(rects[0].namespaceURI).toBe('http://www.w3.org/2000/svg')
expect(rects[0].getAttribute('fill')).toBe('red')
})
it('updates svg template lists reactively', async () => {
const parent = document.createElement('div')
const data = reactive({ values: [25] })
html`
${() =>
data.values.map(
(value, index) => svg` `
)}
`(parent)
data.values = [25, 50]
await nextTick()
const rects = Array.from(parent.querySelectorAll('rect'))
expect(rects).toHaveLength(2)
expect(rects[1].namespaceURI).toBe('http://www.w3.org/2000/svg')
expect(rects[1].getAttribute('height')).toBe('50')
})
// it('renders sanitized HTML when reading from a variable.', () => {
// const data = reactive({
// foo: 'Hello world ',
// })
// expect(html`${() => data.foo}
`().querySelector('h1')).toBe(null)
// })
it('renders sanitized HTML when updating from a variable.', async () => {
const data = reactive({
html: 'foo',
})
const stage = document.createElement('div')
html`${() => data.html}
`(stage)
data.html = 'Some text '
await nextTick()
expect(stage.querySelector('h1')).toBe(null)
})
it('renders keyed list and updates child value without removing/moving any nodes', async () => {
const data = reactive({
list: [
{
id: 1,
name: 'foo',
},
{
id: 2,
name: 'bar',
},
],
})
const stage = document.createElement('div')
html``(stage)
const callback = vi.fn()
const observer = new MutationObserver(callback)
observer.observe(stage.querySelector('ul')!, { childList: true })
const input = stage.querySelector('input') as HTMLInputElement
setValue(input, 'foobar')
await nextTick()
expect(callback).not.toHaveBeenCalled()
})
it('updates plain attributes without reactive attr watchers', async () => {
const data = reactive({ state: 'cold', label: 'Alpha' })
const stage = document.createElement('div')
html`
${() => [html`${data.label} `]}
`(stage)
expect(stage.innerHTML).toBe('\n Alpha \n
')
data.state = 'hot'
data.label = 'Beta'
await nextTick()
expect(stage.innerHTML).toBe('\n Beta \n
')
})
it('updates shifted recalled list items after removal', async () => {
const data = reactive({
list: [
{ id: 1, label: 'one' },
{ id: 2, label: 'two' },
{ id: 3, label: 'three' },
],
})
const stage = document.createElement('div')
html`
${() =>
data.list.map((item) =>
html`${item.label} `
)}
`(stage)
data.list.splice(0, 1)
await nextTick()
expect(stage.innerHTML).toBe(
''
)
})
it('can render an empty template', async () => {
const div = document.createElement('div')
const store = reactive({ show: true })
expect(() =>
html`${() => (store.show ? html` ` : html``)}`(div)
).not.toThrow()
expect(div.innerHTML).toBe(' ')
store.show = false
await nextTick()
expect(div.innerHTML).toBe('')
})
it('can render an array of items and mutate an item in the array (#49)', async () => {
const div = document.createElement('div')
const data = reactive({ order: [1, 2, 3] })
html`
${() => data.order.map((item) => html`${item} `)}
`(div)
data.order[1] += 10
await nextTick()
expect(div.innerHTML).toMatchSnapshot()
})
it('can set any arbitrary IDL attribute', async () => {
const div = document.createElement('div')
class XFoo extends HTMLDivElement {
foo: string
constructor() {
super()
this.foo = 'bar'
}
}
customElements.define('x-foo', XFoo, { extends: 'div' })
const data = reactive({ foo: 'bim' })
html` `(div)
const x = div.querySelector('x-foo') as XFoo
expect(x.foo).toBe('bim')
data.foo = 'baz'
await nextTick()
expect(x.foo).toBe('baz')
expect(x.getAttribute('foo')).toBe(null)
})
it('reuses a detached chunk by explicit template id', async () => {
const host = document.createElement('div')
const state = reactive({ show: true, label: 'alpha' })
html`${() =>
state.show
? html`${() => state.label} `.id('probe')
: html``}`(host)
const first = host.querySelector('[data-probe="id"]') as HTMLButtonElement
state.show = false
await nextTick()
expect(host.querySelector('[data-probe="id"]')).toBeNull()
state.label = 'beta'
state.show = true
await nextTick()
const second = host.querySelector('[data-probe="id"]') as HTMLButtonElement
expect(second).toBe(first)
expect(second.textContent).toBe('beta')
})
it('keeps event listeners live when a detached chunk is revived', async () => {
const host = document.createElement('div')
const state = reactive({ show: true, clicks: 0 })
html`${() =>
state.show
? html` state.clicks++}">
${() => state.clicks}
`.id('reuse-button')
: html``}`(host)
const first = host.querySelector('[data-probe="reuse"]') as HTMLButtonElement
first.click()
await nextTick()
expect(first.textContent?.trim()).toBe('1')
state.show = false
await nextTick()
state.show = true
await nextTick()
const second = host.querySelector('[data-probe="reuse"]') as HTMLButtonElement
expect(second).toBe(first)
second.click()
await nextTick()
expect(second.textContent?.trim()).toBe('2')
})
it('reuses a detached chunk for the same static signature without an explicit id', async () => {
const host = document.createElement('div')
const firstState = reactive({ show: true, label: 'first' })
const secondState = reactive({ show: true, label: 'second' })
const ViewA = () => html`${firstState.label} `
const ViewB = () => html`${secondState.label} `
html`${() => (firstState.show ? ViewA() : html``)}`(host)
const first = host.querySelector('[data-probe="sig"]') as HTMLLIElement
firstState.show = false
await nextTick()
html`${() => (secondState.show ? ViewB() : html``)}`(host)
const second = host.querySelector('[data-probe="sig"]') as HTMLLIElement
expect(second).toBe(first)
expect(second.textContent).toBe('second')
})
it('throws when the same stale id is reused with a different static signature', async () => {
const host = document.createElement('div')
const state = reactive({ show: true })
html`${() => (state.show ? html`alpha
`.id('shape-check') : html``)}`(host)
state.show = false
await nextTick()
expect(() => html`beta `.id('shape-check')(host)).toThrow(
/shape mismatch/
)
})
it('supports compiler-generated property bindings like innerHTML', async () => {
const host = document.createElement('div')
const state = reactive({ markup: 'Ready ' })
const strings = ['
']
html`${() => html(strings, state.markup)}`(host)
const target = host.querySelector('[data-probe="summary"]') as HTMLDivElement
expect(target.innerHTML).toBe('Ready ')
state.markup = 'Done '
await nextTick()
expect(target.innerHTML).toBe('Done ')
})
it('swaps compiler-generated templates when a condition changes', async () => {
const host = document.createElement('div')
const state = reactive({ accountType: 'business' })
const company = ['Company field
']
const personal = ['Personal account
']
html`${() =>
state.accountType !== 'personal' ? html(company) : html(personal)}`(host)
expect(host.textContent).toContain('Company field')
state.accountType = 'personal'
await nextTick()
expect(host.textContent).toContain('Personal account')
expect(host.textContent).not.toContain('Company field')
})
})
describe('html text nodes', () => {
it('updates the the text node itself rather than creating new ones', async () => {
const parent = document.createElement('div')
const data = reactive({ text: 'foo' })
html`${() => data.text} `(parent)
const initialNode = parent.children[0].childNodes[0]
expect(initialNode).toBeInstanceOf(Text)
expect(initialNode.nodeValue).toBe('foo')
data.text = 'bar'
await nextTick()
const postNode = parent.children[0].childNodes[0]
expect(postNode.nodeValue).toBe('bar')
expect(initialNode === postNode).toBe(true)
})
})