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`` const inactiveRoot = document.createElement('div') template(inactiveRoot) const capture = createHydrationCapture() installHydrationCaptureProvider(() => capture) try { const activeTemplate = html`` 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( '
beforetextend
' ) data.active = true await nextTick() expect(parent.innerHTML).toBe( '
beforeafterend
' ) expect(parent.querySelector('span')).toBe(before) expect(parent.querySelector('em')).toBe(end) data.active = false await nextTick() expect(parent.innerHTML).toBe( '
beforetextend
' ) 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 `(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 `(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 `(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 `(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``(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``(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``(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 `(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``(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``(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``(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``(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``(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} ` ), }) const parent = document.createElement('div') html`${() => data.list}
    `(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`
      ${() => list(data.food)}
    `(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(`
    • Justin
    • Luan
    • Andrew
    `) const first = parent.querySelector('li') data.list[0].name = 'Bob' await nextTick() expect(first).toBe(parent.querySelector('li')) expect(parent.innerHTML).toBe(`
    • Bob
    • Luan
    • Andrew
    `) }) 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(`
    • Justin
    • Luan
    • Andrew
    `) // 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(`
    • Luan
    • Andrew
    • Justin
    • Fred
    `) }) 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(`
      //
    • Andrew
    • Justin
    • Luan
    • //
    `) }) 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(`
    • Bob
    • Jeff
    • Fred
    `) data.list[2].name = 'Ted' await nextTick() expect(parent.innerHTML).toBe(`
    • Bob
    • Jeff
    • Ted
    `) }) 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(`
    • Alpha
    • Beta
    `) data.list[0].active = true await nextTick() expect(parent.innerHTML).toBe(`
    • Alpha
    • Beta
    `) data.list[0].active = false data.list[1].active = true await nextTick() expect(parent.innerHTML).toBe(`
    • Alpha
    • Beta
    `) }) 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``(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`
    `( 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`` : html``}`(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``(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`
      ${() => data.list.map((item) => html`
    • ${() => item.name}
    • `.key(item.id) )}
    `(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( '
      \n
    • two
    • three
    • \n
    ' ) }) 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``.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``.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) }) })