@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
561 lines (458 loc) • 16 kB
HTML
<html>
<head>
<title>fromSelector Tests</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div class="test-element" id="original">Content</div>
<script type="module">
import { runTests } from '@web/test-runner-mocha'
import { assert } from '@esm-bundle/chai'
import { fromSelector, computed, effect } from '../../index.dev.js'
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
const animationFrame = async () =>
new Promise(requestAnimationFrame)
const microtask = async () => new Promise(queueMicrotask)
runTests(() => {
describe('fromSelector()', () => {
it('should create a signal returning an empty array for no matching elements', () => {
const signal = fromSelector('.nonexistent')(document)
assert.deepEqual(signal.get(), [])
})
it('should return an array of elements matching the selector', () => {
const signal = fromSelector('.test-element')(document)
const elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
})
it('should update the signal when elements are added or removed', () => {
const signal = fromSelector('.test-element')(document)
const newElement = document.createElement('div')
newElement.classList.add('test-element')
document.body.appendChild(newElement)
let elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.remove()
elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
})
it('should update the signal when matching class is added or removed', () => {
const signal = fromSelector('.test-element')(document)
const newElement = document.createElement('div')
document.body.appendChild(newElement)
let elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.classList.add('test-element')
elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.classList.remove('test-element')
elements = Array.from(
document.querySelectorAll('.test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.remove()
})
it('should update the signal when matching id is added or removed', () => {
const signal = fromSelector('#test-element')(document)
const newElement = document.createElement('div')
document.body.appendChild(newElement)
let elements = Array.from(
document.querySelectorAll('#test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.id = 'test-element'
elements = Array.from(
document.querySelectorAll('#test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.removeAttribute('id')
elements = Array.from(
document.querySelectorAll('#test-element'),
)
assert.deepEqual(signal.get(), elements)
newElement.remove()
})
it('should update the computed signal watching the element selection when elements are added or removed', async () => {
const signal = fromSelector('.test-element')(document)
const contents = computed(elements =>
signal.get().map(element => element.textContent),
)
// Wait for initial setup
await microtask()
assert.deepEqual(contents.get(), ['Content'])
const newElement = document.createElement('div')
newElement.textContent = 'New Content'
newElement.classList.add('test-element')
document.body.appendChild(newElement)
await microtask()
assert.deepEqual(contents.get(), [
'Content',
'New Content',
])
newElement.remove()
await microtask()
assert.deepEqual(contents.get(), ['Content'])
})
it('should apply the effect to an updated array of elements when elements are added or removed', async () => {
const signal = fromSelector('.test-element')(document)
document
.querySelectorAll('.test-element')
.forEach(element => {
element.hidden = false
})
const cleanup = effect({
signals: [signal],
ok: elements =>
elements
.filter(element => !element.hidden)
.map(element => {
element.hidden = true
}),
})
// Wait for initial effect to run
await microtask()
const newElement = document.createElement('div')
newElement.classList.add('test-element')
newElement.classList.add('test-element')
document.body.appendChild(newElement)
await microtask()
let expected = Array.from(
document.querySelectorAll('.test-element'),
).filter(element => element.hidden === true)
assert.equal(expected.length, 2)
document
.querySelectorAll('.test-element')
.forEach(element => {
element.hidden = false
})
newElement.remove()
await microtask()
expected = Array.from(
document.querySelectorAll('.test-element'),
).filter(element => element.hidden === true)
assert.equal(expected.length, 1)
document
.querySelectorAll('.test-element')
.forEach(element => {
element.hidden = false
})
cleanup()
})
it('should handle complex selectors with multiple attributes', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal = fromSelector(
'div.active[role="button"][data-test="value"]',
)(container)
// Initially empty
assert.deepEqual(signal.get(), [])
// Add element with only class
const partial = document.createElement('div')
partial.classList.add('active')
container.appendChild(partial)
await microtask()
assert.deepEqual(signal.get(), [])
// Add role attribute
partial.setAttribute('role', 'button')
await microtask()
assert.deepEqual(signal.get(), [])
// Add data attribute to complete match
partial.setAttribute('data-test', 'value')
await microtask()
assert.deepEqual(signal.get(), [partial])
// Remove role to break match
partial.removeAttribute('role')
await microtask()
assert.deepEqual(signal.get(), [])
} finally {
container.remove()
}
})
it('should handle attribute selectors with different operators', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal = fromSelector(
'[data-prefix^="test"]',
)(container)
const element1 = document.createElement('div')
element1.setAttribute('data-prefix', 'testing')
container.appendChild(element1)
await microtask()
assert.deepEqual(signal.get(), [element1])
const element2 = document.createElement('div')
element2.setAttribute('data-prefix', 'other')
container.appendChild(element2)
await microtask()
assert.deepEqual(signal.get(), [element1])
element2.setAttribute('data-prefix', 'test-value')
await microtask()
assert.deepEqual(signal.get(), [element1, element2])
} finally {
container.remove()
}
})
it('should properly disconnect observer when no watchers exist', () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal = fromSelector('.test')(container)
// Get value to create observer
signal.get()
// Add element to trigger mutation
const element = document.createElement('div')
element.classList.add('test')
container.appendChild(element)
// Observer should disconnect automatically since no watchers
assert.deepEqual(signal.get(), [element])
} finally {
container.remove()
}
})
it('should handle rapid DOM mutations efficiently', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal =
fromSelector('.rapid-test')(container)
let updateCount = 0
const cleanup = effect({
signals: [signal],
ok: () => {
updateCount++
},
})
// Rapid mutations
for (let i = 0; i < 10; i++) {
const element = document.createElement('div')
element.classList.add('rapid-test')
container.appendChild(element)
}
// Should handle rapid mutations without excessive updates
assert.isAtLeast(updateCount, 1)
assert.isAtMost(updateCount, 15) // Allow some batching variance
cleanup()
} finally {
container.remove()
}
})
it('should detect circular mutations and throw CircularMutationError', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal = fromSelector('.circular')(container)
let errorThrown = false
const cleanup = effect({
signals: [signal],
ok: elements => {
// Create circular mutation by adding element in response to signal change
if (elements.length < 3) {
const newElement =
document.createElement('div')
newElement.classList.add('circular')
container.appendChild(newElement)
}
},
error: error => {
errorThrown = true
assert.instanceOf(error, Error)
assert.include(error.message, 'circular')
},
})
// Trigger the circular mutation
const initialElement = document.createElement('div')
initialElement.classList.add('circular')
container.appendChild(initialElement)
// Wait for effects to run
await animationFrame()
// Either error should be caught or effect should have run multiple times
assert.isTrue(
errorThrown || signal.get().length >= 3,
'Should either throw circular mutation error or handle rapid mutations',
)
cleanup()
} finally {
container.remove()
}
})
it('should create a signal producer that selects elements', () => {
const container = document.createElement('div')
container.innerHTML = `
<div class="item" data-value="1">Item 1</div>
<div class="item" data-value="2">Item 2</div>
<p>Not an item</p>
`
document.body.appendChild(container)
try {
const signal = fromSelector('.item')(container)
const items = signal.get()
assert.equal(items.length, 2)
assert.equal(items[0].dataset.value, '1')
assert.equal(items[1].dataset.value, '2')
} finally {
container.remove()
}
})
it('should update when matching elements change', async () => {
const container = document.createElement('div')
container.innerHTML = `<div class="item">Original</div>`
document.body.appendChild(container)
try {
const signal = fromSelector('.item')(container)
assert.equal(signal.get().length, 1)
// Add element
const newItem = document.createElement('div')
newItem.className = 'item'
newItem.textContent = 'New'
container.appendChild(newItem)
assert.equal(signal.get().length, 2)
// Remove element
newItem.remove()
assert.equal(signal.get().length, 1)
} finally {
container.remove()
}
})
it('should work with complex selectors', () => {
const container = document.createElement('div')
container.innerHTML = `
<form class="user-form">
<input type="email" required name="email" />
<input type="text" name="name" />
<button type="submit">Submit</button>
</form>
`
document.body.appendChild(container)
try {
const signal = fromSelector(
'form.user-form input[required]',
)(container)
const requiredInputs = signal.get()
assert.equal(requiredInputs.length, 1)
assert.equal(requiredInputs[0].type, 'email')
assert.equal(requiredInputs[0].name, 'email')
} finally {
container.remove()
}
})
it('should handle selection with rapidly changing DOM', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal = fromSelector('.dynamic')(container)
let lastCount = 0
const cleanup = effect({
signals: [signal],
ok: elements => {
lastCount = elements.length
},
})
// Add elements rapidly
const elements = []
for (let i = 0; i < 5; i++) {
const element = document.createElement('div')
element.className = 'dynamic'
element.textContent = `Item ${i}`
container.appendChild(element)
elements.push(element)
}
await animationFrame()
assert.equal(lastCount, 5)
// Remove elements
elements.forEach(el => el.remove())
await animationFrame()
assert.equal(lastCount, 0)
cleanup()
} finally {
container.remove()
}
})
it('should handle events during DOM mutations', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal =
fromSelector('.event-test')(container)
let eventCount = 0
const cleanup = effect({
signals: [signal],
ok: elements => {
elements.forEach(element => {
element.addEventListener(
'click',
() => {
eventCount++
},
)
})
},
})
// Add element and trigger event
const element = document.createElement('div')
element.className = 'event-test'
container.appendChild(element)
await animationFrame()
element.click()
assert.equal(eventCount, 1)
// Add another element
const element2 = document.createElement('div')
element2.className = 'event-test'
container.appendChild(element2)
await animationFrame()
element2.click()
assert.equal(eventCount, 2)
cleanup()
} finally {
container.remove()
}
})
it('should handle memory cleanup properly', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
try {
const signal =
fromSelector('.cleanup-test')(container)
let effectRuns = 0
const cleanup = effect({
signals: [signal],
ok: () => {
effectRuns++
},
})
// Add and remove elements
for (let i = 0; i < 3; i++) {
const element = document.createElement('div')
element.className = 'cleanup-test'
container.appendChild(element)
await microtask()
element.remove()
await microtask()
}
const initialRuns = effectRuns
// Cleanup effect
cleanup()
// Add more elements - should not trigger effects
const element = document.createElement('div')
element.className = 'cleanup-test'
container.appendChild(element)
assert.equal(effectRuns, initialRuns)
} finally {
container.remove()
}
})
})
})
</script>
</body>
</html>