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( , ) root.flush() let button = container.querySelector('button') invariant(button) button.click() root.flush() expect(calls).toEqual(['base', 'a', 'b']) }) it('supports on mixin helper composition standalone', () => { let calls: string[] = [] let container = document.createElement('div') let root = createRoot(container) root.render( , ) root.flush() let button = container.querySelector('button') invariant(button) button.click() root.flush() expect(calls).toEqual(['first', 'second']) }) it('updates only host props when mixin calls handle.update', () => { let appRenderCount = 0 let withCounter = createMixin((handle) => { let count = 0 return (props: { ['data-count']?: string }) => ( { count++ handle.update() }), ]} /> ) }) function App(_handle: Handle) { appRenderCount++ return () => } let container = document.createElement('div') let root = createRoot(container) root.render() root.flush() let button = container.querySelector('button') invariant(button) expect(button.getAttribute('data-count')).toBe('0') expect(appRenderCount).toBe(1) button.click() root.flush() expect(button.getAttribute('data-count')).toBe('1') expect(appRenderCount).toBe(1) }) it('dispatches reclaimed on persisted reuse without rerunning insert or remove', async () => { let insertCalls = 0 let reclaimedCalls = 0 let removeCalls = 0 let beforeRemoveCalls = 0 let resolvePending: (() => void) | null = null let withReclaimLifecycle = createMixin((handle) => { handle.addEventListener('insert', () => { insertCalls++ }) handle.addEventListener('reclaimed', () => { reclaimedCalls++ }) handle.addEventListener('beforeRemove', (event) => { beforeRemoveCalls++ event.persistNode( (signal) => new Promise((resolve) => { let done = () => resolve() resolvePending = done signal.addEventListener('abort', done, { once: true }) }), ) }) handle.addEventListener('remove', () => { removeCalls++ }) return (props: { id?: string }) => }) let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() expect(insertCalls).toBe(1) expect(reclaimedCalls).toBe(0) expect(removeCalls).toBe(0) root.render(null) root.flush() await Promise.resolve() expect(beforeRemoveCalls).toBe(1) expect(removeCalls).toBe(0) root.render(
) root.flush() await Promise.resolve() expect(insertCalls).toBe(1) expect(reclaimedCalls).toBe(1) expect(removeCalls).toBe(0) if (resolvePending !== null) { ;(resolvePending as () => void)() } }) it('defers host removal when beforeRemove.persistNode is used', async () => { let releaseRemoval: (() => void) | null = null let beforeRemoveCalls = 0 let removeCalls = 0 let withDeferredRemove = createMixin((handle) => { handle.addEventListener('beforeRemove', (event) => { beforeRemoveCalls++ event.persistNode( () => new Promise((resolve) => { releaseRemoval = () => resolve() }), ) }) handle.addEventListener('remove', () => { removeCalls++ }) return (props: { id?: string }) => }) let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let beforeRemove = container.querySelector('#deferred-remove') invariant(beforeRemove) root.render(null) root.flush() await Promise.resolve() expect(beforeRemoveCalls).toBe(1) expect(removeCalls).toBe(0) expect(container.querySelector('#deferred-remove')).toBe(beforeRemove) let release = releaseRemoval ?? (() => { throw new Error('expected deferred remove callback') }) release() await new Promise((resolve) => setTimeout(resolve, 0)) expect(removeCalls).toBe(1) expect(container.querySelector('#deferred-remove')).toBe(null) }) })