import { describe, it, expect } from 'vitest'
import type { Handle } from '@remix-run/component'
import { render } from '../render.ts'
describe('mount', () => {
it('performs diffing instead of clearing innerHTML', () => {
let element = document.createElement('div')
let renderCount = 0
let lastProps: any = null
function TestComponent(this: Handle, setupProps: { value: string }) {
return (props: { value: string }) => {
renderCount++
lastProps = { value: props.value }
return
Value: {props.value}
}
}
// First mount
render(, element)
expect(element.innerHTML).toContain('Value: first')
expect(renderCount).toBe(1)
expect(lastProps.value).toBe('first')
// Get reference to the DOM node to verify it's preserved
let contentDiv = element.querySelector('[data-testid="content"]') as HTMLElement
let originalDiv = contentDiv
// Second mount - should diff instead of replacing
render(, element)
expect(element.innerHTML).toContain('Value: second')
expect(renderCount).toBe(2)
expect(lastProps.value).toBe('second')
// Verify the same DOM node was reused (diffed) instead of replaced
contentDiv = element.querySelector('[data-testid="content"]') as HTMLElement
expect(contentDiv).toBe(originalDiv) // Same DOM node reference
})
it('preserves DOM state across updates', () => {
let element = document.createElement('div')
function TestComponent(this: Handle, setupProps: { label: string }) {
return (props: { label: string }) => (
)
}
// First mount
render(, element)
// User types in the input
let input = element.querySelector('[data-testid="test-input"]') as HTMLInputElement
input.value = 'user typed text'
// Second mount with different label
render(, element)
// Input value should be preserved due to diffing
input = element.querySelector('[data-testid="test-input"]') as HTMLInputElement
expect(input.value).toBe('user typed text')
expect(element.innerHTML).toContain('Second Label')
})
it('handles component replacement correctly', () => {
let element = document.createElement('div')
function ComponentA() {
return
Component A
}
function ComponentB() {
return
Component B
}
// Mount Component A
let root = render(, element)
expect(element.innerHTML).toContain('Component A')
expect(element.querySelector('[data-component="A"]')).toBeTruthy()
// Mount Component B - should replace A
root.update()
expect(element.innerHTML).toContain('Component B')
expect(element.querySelector('[data-component="B"]')).toBeTruthy()
expect(element.querySelector('[data-component="A"]')).toBeFalsy()
})
})
describe('RootInstance.cleanup()', () => {
it('calls all component cleanup functions', () => {
let element = document.createElement('div')
let setupCleanupCalled = false
let renderCleanupCalled = false
function TestComponent(this: Handle) {
// Setup-time cleanup
this.onCleanup(() => {
setupCleanupCalled = true
})
// Setup-time afterRender with cleanup
this.afterRender(() => {
return () => {
renderCleanupCalled = true
}
})
return () =>
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
expect(setupCleanupCalled).toBe(false)
expect(renderCleanupCalled).toBe(false)
// Call cleanup - should trigger all cleanup functions
root.unmount()
expect(setupCleanupCalled).toBe(true)
expect(renderCleanupCalled).toBe(true)
})
it('calls ref cleanup functions', () => {
let element = document.createElement('div')
let refSetCalls = 0
let refCleanupCalls = 0
function refCallback(node: HTMLElement | null) {
if (node) {
refSetCalls++
} else {
refCleanupCalls++
}
}
function TestComponent() {
return
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
expect(refSetCalls).toBe(1)
expect(refCleanupCalls).toBe(0)
// Call cleanup - should trigger ref cleanup
root.unmount()
expect(refSetCalls).toBe(1) // Still only 1 set call
expect(refCleanupCalls).toBe(0) // No cleanup call since ref doesn't return a cleanup function
})
it('calls event cleanup through component cleanup', () => {
let element = document.createElement('div')
let eventCleanupCalled = false
function TestComponent(this: Handle) {
// Simulate event container cleanup (like in Await component)
let mockEventContainer = {
cleanup: () => {
eventCleanupCalled = true
},
}
// Register event cleanup with component
this.onCleanup(() => {
mockEventContainer.cleanup()
})
return () =>
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
expect(eventCleanupCalled).toBe(false)
// Call cleanup - should trigger event cleanup
root.unmount()
expect(eventCleanupCalled).toBe(true)
})
it('clears container DOM', () => {
let element = document.createElement('div')
function TestComponent() {
return (
Title
Content
)
}
let root = render(, element)
expect(element.children.length).toBeGreaterThan(0)
expect(element.innerHTML).toContain('Title')
expect(element.innerHTML).toContain('Content')
expect(element.innerHTML).toContain('Button')
// Call cleanup - should clear all DOM content
root.unmount()
expect(element.children.length).toBe(0)
expect(element.innerHTML).toBe('')
})
it('removes root from tracking', () => {
let element = document.createElement('div')
function TestComponent() {
return
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
// Call cleanup
root.unmount()
// Mount again - should create a new root instance (not reuse the old one)
let newRoot = render(, element)
expect(newRoot).not.toBe(root) // Different root instance
expect(element.innerHTML).toContain('Test content')
})
it('marks root as unmounted', () => {
let element = document.createElement('div')
function TestComponent() {
return
Test content
}
let root = render(, element)
// Call cleanup
root.unmount()
expect(element.innerHTML).toBe('')
})
it('handles nested component cleanup', () => {
let element = document.createElement('div')
let parentCleanupCalled = false
let childCleanupCalled = false
function ChildComponent(this: Handle) {
this.onCleanup(() => {
childCleanupCalled = true
})
return () =>
)
}
let root = render(, element)
expect(element.innerHTML).toContain('Parent content')
expect(element.innerHTML).toContain('Child content')
expect(parentCleanupCalled).toBe(false)
expect(childCleanupCalled).toBe(false)
// Call cleanup - should trigger cleanup for both parent and child
root.unmount()
expect(parentCleanupCalled).toBe(true)
expect(childCleanupCalled).toBe(true)
})
it('safely handles multiple calls', () => {
let element = document.createElement('div')
let cleanupCallCount = 0
function TestComponent(this: Handle) {
this.onCleanup(() => {
cleanupCallCount++
})
return () =>
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
// Call cleanup multiple times
root.unmount()
expect(cleanupCallCount).toBe(1)
expect(element.innerHTML).toBe('')
// Should be safe to call again
root.unmount()
expect(cleanupCallCount).toBe(1) // Should not increment again
expect(element.innerHTML).toBe('')
})
})
describe('refs returning cleanup functions', () => {
it('calls correctly (not with null)', () => {
let element = document.createElement('div')
let refCalls: Array<{ type: string; element: HTMLElement | null }> = []
let cleanupCalled = false
function refFunction(node: HTMLElement | null) {
refCalls.push({ type: 'ref-called', element: node })
if (node === null) {
throw new Error('Ref should not be called with null when it returns a cleanup function!')
}
// Return cleanup function
return () => {
cleanupCalled = true
refCalls.push({ type: 'cleanup-called', element: null })
}
}
function TestComponent() {
return
Test content
}
let root = render(, element)
expect(element.innerHTML).toContain('Test content')
expect(refCalls).toEqual([{ type: 'ref-called', element: expect.any(HTMLElement) }])
expect(cleanupCalled).toBe(false)
// Cleanup should call the returned cleanup function, not the ref with null
root.unmount()
expect(cleanupCalled).toBe(true)
expect(refCalls).toEqual([
{ type: 'ref-called', element: expect.any(HTMLElement) },
{ type: 'cleanup-called', element: null },
])
})
})