import { describe, it, expect, vi } from 'vitest'
import { createElement } from '../lib/create-element.ts'
import { createRoot } from '../lib/vdom.ts'
import { createMixin, on, ref } from '../index.ts'
import { invariant } from '../lib/invariant.ts'
import type { Handle, RemixNode } from '../lib/component.ts'
import type { Props } from '../index.ts'
describe('vnode mixins', () => {
it('composes mixins in order and does not leak mix to the DOM', () => {
let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => (
))
let appendTitle = createMixin((handle) => (suffix: string, props: { title?: string }) => (
))
let container = document.createElement('div')
let root = createRoot(container)
root.render(
)
let div = container.querySelector('div')
invariant(div)
expect(div.getAttribute('title')).toBe('hello-world')
expect(div.hasAttribute('mix')).toBe(false)
})
it('supports nested mix descriptors via handle.element', () => {
let withData = createMixin((handle) => (value: string, props: { ['data-mixed']?: string }) => (
))
let withNested = createMixin(
(handle) => (value: string, props: { ['data-mixed']?: string }) => (
),
)
let container = document.createElement('div')
let root = createRoot(container)
root.render()
let div = container.querySelector('div')
invariant(div)
expect(div.getAttribute('data-mixed')).toBe('nested')
})
it('supports createElement(handle.element, props) inside mixins', () => {
let withData = createMixin(
(handle) => (value: string, props: { ['data-mixed']?: string }) =>
createElement(handle.element, { ...props, 'data-mixed': value }),
)
let container = document.createElement('div')
let root = createRoot(container)
root.render(
child
)
root.flush()
let div = container.querySelector('div')
invariant(div)
expect(div.getAttribute('data-mixed')).toBe('created')
expect(div.textContent).toBe('child')
})
it('strips children and innerHTML before passing props to mixins', () => {
let seenProps: Array> = []
let inspect = createMixin((_handle) => (props: Record) => {
seenProps.push(props)
})
let container = document.createElement('div')
let root = createRoot(container)
root.render(
child
)
root.flush()
root.render()
root.flush()
expect('children' in seenProps[0]!).toBe(false)
expect('innerHTML' in seenProps[0]!).toBe(false)
expect('children' in seenProps[1]!).toBe(false)
expect('innerHTML' in seenProps[1]!).toBe(false)
})
it('ignores children returned from mixins while preserving host content', () => {
let withChildren = createMixin(
(handle) => () =>
createElement(handle.element as any, { 'data-mode': 'children' }, 'blocked'),
)
let errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
let container = document.createElement('div')
let root = createRoot(container)
root.render(
safe
)
root.flush()
expect(container.querySelector('div')?.dataset.mode).toBe('children')
expect(container.querySelector('div')?.textContent).toBe('safe')
expect(errorSpy).toHaveBeenCalledTimes(1)
expect((errorSpy.mock.calls[0]?.[0] as Error).message).toBe(
'mixin elements must not receive children',
)
} finally {
errorSpy.mockRestore()
}
})
it('ignores innerHTML returned from mixins while preserving host content', () => {
let withInnerHtml = createMixin((_handle) => () => (
))
let errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
let container = document.createElement('div')
let root = createRoot(container)
root.render(
safe
)
root.flush()
expect(container.querySelector('div')?.dataset.mode).toBe('innerHTML')
expect(container.querySelector('div')?.textContent).toBe('safe')
expect(errorSpy).toHaveBeenCalledTimes(1)
expect((errorSpy.mock.calls[0]?.[0] as Error).message).toBe(
'mixins must not return children or innerHTML',
)
} finally {
errorSpy.mockRestore()
}
})
it('supports mixins returning nested descriptors directly', () => {
let withData = createMixin((handle) => (value: string, props: { ['data-mixed']?: string }) => (
))
let withReturnedMix = createMixin((_handle) => (value) => [
false,
[withData(value)],
undefined,
])
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
let div = container.querySelector('div')
invariant(div)
expect(div.getAttribute('data-mixed')).toBe('returned')
})
it('normalizes component mix props so wrapped hosts can compose them', () => {
let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => (
))
let appendTitle = createMixin((handle) => (suffix: string, props: { title?: string }) => (
))
function Button() {
return ({ children, mix, ...props }: Props<'button'>) => (
)
}
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
let button = container.querySelector('button')
invariant(button)
expect(button.getAttribute('title')).toBe('base-override')
expect(button.hasAttribute('mix')).toBe(false)
})
it('shares one handle instance across mixins on the same host node', () => {
let handles: unknown[] = []
let one = createMixin((handle) => {
handles.push(handle)
})
let two = createMixin((handle) => {
handles.push(handle)
})
let three = createMixin((handle) => {
handles.push(handle)
})
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
expect(handles.length).toBe(3)
expect(handles[0]).toBe(handles[1])
expect(handles[1]).toBe(handles[2])
})
it('reads ancestor component context from mixins', () => {
function Provider(handle: Handle<{ value: string }>) {
return ({ children }: { children?: RemixNode }) => {
handle.context.set({ value: 'from-context' })
return {children}
}
}
let withContextValue = createMixin((handle) => (props: { ['data-value']?: string }) => {
let provider = handle.context.get(Provider)
return
})
let container = document.createElement('div')
let root = createRoot(container)
root.render(
,
)
root.flush()
let div = container.querySelector('div')
invariant(div)
expect(div.dataset.value).toBe('from-context')
})
it('aborts handle.signal when the host node is removed', () => {
let signal = AbortSignal.abort()
let withSignal = createMixin((handle) => {
signal = handle.signal
})
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
expect(signal.aborted).toBe(false)
root.render(null)
root.flush()
expect(signal.aborted).toBe(true)
})
it('aborts handle.signal when a mixin slot is removed while the host stays mounted', () => {
let keptSignal = AbortSignal.abort()
let removedSignal = AbortSignal.abort()
let keepSignal = createMixin((handle) => {
keptSignal = handle.signal
})
let removeSignal = createMixin((handle) => {
removedSignal = handle.signal
})
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
expect(keptSignal.aborted).toBe(false)
expect(removedSignal.aborted).toBe(false)
root.render()
root.flush()
expect(keptSignal.aborted).toBe(false)
expect(removedSignal.aborted).toBe(true)
expect(container.querySelector('div')).toBeInstanceOf(HTMLDivElement)
})
it('supports setup-only passthrough mixins', () => {
let withPassthrough = createMixin((_handle) => {})
let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => (
))
let container = document.createElement('div')
let root = createRoot(container)
root.render()
root.flush()
let div = container.querySelector('div')
invariant(div)
expect(div.getAttribute('title')).toBe('ok')
})
it('does not duplicate on handlers for passthrough mixins', () => {
let clicks = 0
let passthrough = createMixin((_handle) => {})
let container = document.createElement('div')
let root = createRoot(container)
root.render(