import { describe, it, expect } from 'vitest' import { dom } from '@remix-run/events' import type { Props } from '../html-types.ts' import { render } from '../render.ts' import type { RemixNode, Handle } from '@remix-run/component' import { flushRenderQueue as flush } from '../reconcile.ts' describe('property vs attribute mapping', () => { it('sets input value via property and preserves caret on rerender', () => { let container = document.createElement('div') let text = 'hello' function App() { return } let root = render(, container) let input = container.querySelector('input') as HTMLInputElement expect(input.value).toBe('hello') // Simulate user cursor at index 3 input.focus() input.setSelectionRange(3, 3) // Re-render with the same value, caret should remain root.update() expect(input.value).toBe('hello') expect(input.selectionStart).toBe(3) expect(input.selectionEnd).toBe(3) // Update value and ensure property changes text = 'hello!' root.update() expect(input.value).toBe('hello!') }) it('toggles checkbox checked via property, independent of attribute', () => { let container = document.createElement('div') let checked = false function App() { return } let root = render(, container) let input = container.querySelector('input') as HTMLInputElement expect(input.checked).toBe(false) expect(input.getAttribute('checked')).toBeNull() checked = true root.update() expect(input.checked).toBe(true) // Attribute may not be present; property is the source of truth expect(input.getAttribute('checked') === '' || input.getAttribute('checked') === null).toBe( true, ) }) it('updates select multiple and selectedIndex via properties', () => { let container = document.createElement('div') let multi = false let idx = 1 function App() { return ( ) } let root = render(, container) let select = container.querySelector('select') as HTMLSelectElement expect(select.multiple).toBe(false) expect(select.selectedIndex).toBe(1) multi = true idx = 2 root.update() expect(select.multiple).toBe(true) expect(select.selectedIndex).toBe(2) }) it('keeps SVG attributes attribute-based', () => { let container = document.createElement('div') let r = 40 function App() { return ( ) } let root = render(, container) let circle = container.querySelector('circle')! expect(circle.getAttribute('r')).toBe('40') r = 20 root.update() expect(circle.getAttribute('r')).toBe('20') }) it('reordering keyed inputs preserves typed values and submitted FormData (uncontrolled)', () => { let container = document.createElement('div') let order = ['one', 'two', 'three'] function App() { return (
{order.map((id) => ( ))}
) } // Initial mount let root = render(, container) // Type into the inputs (simulate user input) let inputOne = container.querySelector('input[name="one"]') as HTMLInputElement let inputTwo = container.querySelector('input[name="two"]') as HTMLInputElement let inputThree = container.querySelector('input[name="three"]') as HTMLInputElement inputOne.value = 'alpha' inputTwo.value = 'beta' inputThree.value = 'gamma' // Reorder the inputs (keys remain the same, DOM nodes should move not recreate) order = ['three', 'one', 'two'] root.update() // Visually (property) values should persist after reorder inputOne = container.querySelector('input[name="one"]') as HTMLInputElement inputTwo = container.querySelector('input[name="two"]') as HTMLInputElement inputThree = container.querySelector('input[name="three"]') as HTMLInputElement expect(inputOne.value).toBe('alpha') expect(inputTwo.value).toBe('beta') expect(inputThree.value).toBe('gamma') // Submitted values should reflect the current input values let form = container.querySelector('form') as HTMLFormElement let formData = new FormData(form) expect(formData.get('one')).toBe('alpha') expect(formData.get('two')).toBe('beta') expect(formData.get('three')).toBe('gamma') }) it('reordering keyed inputs preserves typed values and submitted FormData (controlled via value prop)', () => { let container = document.createElement('div') let order = ['one', 'two', 'three'] let values: Record = { one: '', two: '', three: '' } function App() { return (
{order.map((id) => ( ))}
) } // Initial mount let root = render(, container) // Simulate user typing by setting values and re-rendering (controlled pattern) values = { one: 'alpha', two: 'beta', three: 'gamma' } root.update() // Reorder the inputs order = ['three', 'one', 'two'] root.update() // Values should remain correct by name (as controlled props) let inputOne = container.querySelector('input[name="one"]') as HTMLInputElement let inputTwo = container.querySelector('input[name="two"]') as HTMLInputElement let inputThree = container.querySelector('input[name="three"]') as HTMLInputElement expect(inputOne.value).toBe('alpha') expect(inputTwo.value).toBe('beta') expect(inputThree.value).toBe('gamma') // Form submission should reflect the same values let form = container.querySelector('form') as HTMLFormElement let formData = new FormData(form) expect(formData.get('one')).toBe('alpha') expect(formData.get('two')).toBe('beta') expect(formData.get('three')).toBe('gamma') }) }) describe('innerHTML behavior', () => { it('innerHTML prop replaces children content', () => { let element = document.createElement('div') function App() { return
Bold text'}>This content should be replaced
} let root = render(, element) // innerHTML should work and replace children expect(element.innerHTML).toContain('Bold text') expect(element.innerHTML).not.toContain('This content should be replaced') expect(element.innerHTML).toBe('
Bold text
') }) it('innerHTML allows unescaped HTML for intentional use', () => { let element = document.createElement('div') function App() { return (
Emphasized and bold text'}>Replaced content
) } let root = render(, element) expect(element.innerHTML).toContain('Emphasized') expect(element.innerHTML).toContain('bold') expect(element.innerHTML).not.toContain('Replaced content') }) it('escapes text content safely', () => { let element = document.createElement('div') function App() { return
{""}
} let root = render(, element) // Text content should be escaped and visible as text expect(element.textContent).toBe("") expect(element.innerHTML).toContain('<script>') }) it('clears innerHTML', () => { let element = document.createElement('div') function App() { return
This should be cleared
} let root = render(, element) expect(element.innerHTML).toBe('
') expect(element.innerHTML).not.toContain('This should be cleared') }) }) describe('on prop normalization', () => { it('normalizes on prop to an array in custom components', () => { let element = document.createElement('div') let consumerClickCount = 0 let componentClickCount = 0 function TestButton({ on = [], ...rest }: Props<'button'>) { // No boilerplate needed! The type system now ensures 'on' is always an array return ( {children} ) } } function Child() { childRenderCount++ return
Child
} render( , element, ) expect(parentRenderCount).toBe(1) expect(childRenderCount).toBe(1) let button = element.querySelector('button')! button.click() flush() expect(parentRenderCount).toBe(2) expect(childRenderCount).toBe(1) }) it('re-renders context consumers but skips intermediate components', () => { let element = document.createElement('div') let parentRenderCount = 0 let intermediateComponentRenderCount = 0 let consumerRenderCount = 0 function Parent(this: Handle) { let count = 0 return ({ children }: { children: RemixNode }) => { this.context.set(count) parentRenderCount++ return (
{children}
) } } function IntermediateComponent({ children }: { children: RemixNode }) { intermediateComponentRenderCount++ return
IntermediateComponent {children}
} function Consumer(this: Handle) { let count = this.context.get(Parent) consumerRenderCount++ return
Consumer: {count}
} render( , element, ) expect(parentRenderCount).toBe(1) expect(intermediateComponentRenderCount).toBe(1) expect(consumerRenderCount).toBe(1) let button = element.querySelector('button')! button.click() flush() expect(parentRenderCount).toBe(2) expect(intermediateComponentRenderCount).toBe(1) expect(consumerRenderCount).toBe(2) }) it('re-renders components in props.children if they also schedule an update in the same render', () => { let element = document.createElement('div') let parentRenderCount = 0 let childRenderCount = 0 let emitter = new EventTarget() function Parent(this: Handle) { let count = 0 emitter.addEventListener('update', () => { count++ this.render() }) return ({ children }: { children: RemixNode }) => { parentRenderCount++ return
Parent: {children}
} } function Child(this: Handle) { let count = 0 return () => { childRenderCount++ emitter.addEventListener('update', () => { count++ this.render() }) return
Child
} } render( , element, ) expect(parentRenderCount).toBe(1) expect(childRenderCount).toBe(1) emitter.dispatchEvent(new Event('update')) flush() expect(parentRenderCount).toBe(2) expect(childRenderCount).toBe(2) }) it('handles multiple components updating from the same event with context', () => { let element = document.createElement('div') let parentRenderCount = 0 let childRenderCount = 0 let grandchildRenderCount = 0 // Simulating Menu structure where multiple components respond to same event function MenuLike(this: Handle<{ isOpen: boolean }>) { let isOpen = false return ({ children }: { children: RemixNode }) => { parentRenderCount++ this.context.set({ isOpen }) return (
{ isOpen = !isOpen this.render() })} > Menu isOpen: {isOpen ? 'true' : 'false'} {children}
) } } function MenuItemLike(this: Handle) { let focused = false return ({ children }: { children: RemixNode }) => { childRenderCount++ let menuContext = this.context.get(MenuLike) return (
{ focused = true this.render() })} > MenuItem focused: {focused ? 'true' : 'false'}, menu open:{' '} {menuContext.isOpen ? 'true' : 'false'} {children}
) } } function NestedItemLike(this: Handle) { return () => { grandchildRenderCount++ let menuContext = this.context.get(MenuLike) return Nested item, menu open: {menuContext.isOpen ? 'true' : 'false'} } } render( , element, ) expect(parentRenderCount).toBe(1) expect(childRenderCount).toBe(1) expect(grandchildRenderCount).toBe(1) // Click on menu item - both Menu and MenuItem should update let menuItemDiv = element.querySelectorAll('div')[1] menuItemDiv.click() flush() // Parent updates (context provider) expect(parentRenderCount).toBe(2) // Child updates (self update) expect(childRenderCount).toBe(2) // Grandchild updates (context consumer) expect(grandchildRenderCount).toBe(2) // Verify state is correct expect(element.innerHTML).toContain('Menu isOpen: true') expect(element.innerHTML).toContain('MenuItem focused: true') expect(element.innerHTML).toContain('menu open: true') }) }) describe('text nodes', () => { it('should render in the right order', () => { let element = document.createElement('div') function App() { return (
span content text content
) } render(, element) expect(element.innerHTML).toBe('
span content text content
') }) it('handles mixed text and elements correctly', () => { let element = document.createElement('div') function App() { return (
first middle last
) } render(, element) expect(element.innerHTML).toBe('
firstmiddlelast
') }) it('normalizes boolean children to render nothing', () => { let element = document.createElement('div') function App() { return (
{true} {false}a{true}b{false}c
) } render(, element) expect(element.innerHTML).toBe('
abc
') }) }) describe('batched effects processing', () => { it('processes refs and events after all DOM updates in batch', () => { let element = document.createElement('div') let refCallOrder: string[] = [] let eventCallOrder: string[] = [] let domAtRefCall: string[] = [] function Parent(this: Handle) { let count = 0 return ({ children }: { children: RemixNode }) => { return (
{ refCallOrder.push('parent-ref') // Capture what the DOM looks like when this ref is called domAtRefCall.push(element.innerHTML) return () => { refCallOrder.push('parent-ref-cleanup') } }} on={dom.click(() => { count++ eventCallOrder.push('parent-event') this.render() })} > Parent: {count} {children}
) } } function Child() { return (
{ refCallOrder.push('child-ref') // Capture what the DOM looks like when this ref is called domAtRefCall.push(element.innerHTML) return () => { refCallOrder.push('child-ref-cleanup') } }} on={dom.click(() => { eventCallOrder.push('child-event') })} > Child content
) } // Initial render render( , element, ) // Both refs should be called, and DOM should be fully updated when refs are called // Child ref is called first due to depth-first processing expect(refCallOrder).toEqual(['child-ref', 'parent-ref']) expect(domAtRefCall.length).toBe(2) // When child ref was called, both parent and child should be in DOM expect(domAtRefCall[0]).toContain('data-parent="parent-0"') expect(domAtRefCall[0]).toContain('data-child="child-element"') // When parent ref was called, both should still be there expect(domAtRefCall[1]).toContain('data-parent="parent-0"') expect(domAtRefCall[1]).toContain('data-child="child-element"') // Reset tracking arrays refCallOrder = [] eventCallOrder = [] domAtRefCall = [] // Now trigger an update that will re-render parent (which causes child to re-render via diffing) let parentDiv = element.querySelector('[data-parent]') as HTMLDivElement parentDiv.click() flush() // No new refs should be called since elements are reused expect(refCallOrder).toEqual([]) expect(eventCallOrder).toEqual(['parent-event']) // The DOM should now show updated parent data expect(element.innerHTML).toContain('data-parent="parent-1"') expect(element.innerHTML).toContain('Parent: 1') }) }) describe('NEEDS_UPDATE flag handling', () => { it('should not ignore subsequent render calls after deduplication', () => { let element = document.createElement('div') let parentRenderCount = 0 let childRenderCount = 0 let childHandle: Handle | null = null function Parent(this: Handle) { let count = 0 return ({ children }: { children: RemixNode }) => { parentRenderCount++ return (
{children}
) } } function Child(this: Handle) { childHandle = this let count = 0 return () => { childRenderCount++ return (
) } } render( , element, ) expect(parentRenderCount).toBe(1) expect(childRenderCount).toBe(1) // Simulate the problematic scenario: // 1. Child schedules update (sets NEEDS_UPDATE flag) // 2. Parent schedules update before microtask flush // 3. Deduplication removes child task but doesn't clear NEEDS_UPDATE flag // 4. Parent renders, which may update child via diff // 5. Child's NEEDS_UPDATE flag is still set // Schedule child update first childHandle!.render() // Immediately schedule parent update (before microtask flush) let parentButton = element.querySelector('button')! parentButton.click() // Flush the queue - parent should render, child task should be deduplicated flush() expect(parentRenderCount).toBe(2) // Child should have rendered once more due to parent's render expect(childRenderCount).toBe(2) // Now try to schedule another child update - this should NOT be ignored let initialChildRenderCount = childRenderCount childHandle!.render() flush() // This assertion will fail with the bug - child render will be ignored // because NEEDS_UPDATE flag is still set from the deduplicated task expect(childRenderCount).toBe(initialChildRenderCount + 1) }) }) describe('keyed children diffing', () => { it('reorders existing keyed children by moving DOM nodes (ABCD -> CDAB)', () => { let element = document.createElement('div') let items = ['A', 'B', 'C', 'D'] function App() { return (
    {items.map((id) => (
  • {id}
  • ))}
) } let root = render(, element) let getOrder = () => Array.from(element.querySelectorAll('li')) .map((n) => n.textContent) .join('') expect(getOrder()).toBe('ABCD') // Reorder only - no insertions or deletions items = ['C', 'D', 'A', 'B'] root.update() expect(getOrder()).toBe('CDAB') }) it('inserts new keyed children among stable keys (ABD -> AxByD)', () => { let element = document.createElement('div') let items: Array = ['A', 'B', 'D'] function App() { return (
    {items.map((id) => (
  • {id}
  • ))}
) } let root = render(, element) let getOrder = () => Array.from(element.querySelectorAll('li')) .map((n) => n.textContent) .join('') expect(getOrder()).toBe('ABD') // Insert x between A and B, y between B and D items = ['A', 'x', 'B', 'y', 'D'] root.update() expect(getOrder()).toBe('AxByD') }) it('handles mixed keyed and unkeyed children ordering (A,X,B -> B,X,A)', () => { let element = document.createElement('div') let order: Array<'A' | 'B' | 'X'> = ['A', 'X', 'B'] function App() { return (
    {order.map((id, index) => { if (id === 'X') { // Unkeyed item return
  • X
  • } return (
  • {id}
  • ) })}
) } let root = render(, element) let getOrder = () => Array.from(element.querySelectorAll('li')) .map((n) => n.textContent) .join('') expect(getOrder()).toBe('AXB') order = ['B', 'X', 'A'] root.update() expect(getOrder()).toBe('BXA') }) it('does not recall refs for moved keyed nodes', () => { let element = document.createElement('div') let items = ['A', 'B', 'C'] let mountCount = 0 let cleanupCount = 0 function App() { return (
    {items.map((id) => (
  • { mountCount++ return () => { cleanupCount++ } }} > {id}
  • ))}
) } let root = render(, element) expect(mountCount).toBe(3) expect(cleanupCount).toBe(0) // Reorder without creating/removing nodes items = ['C', 'A', 'B'] root.update() expect(mountCount).toBe(3) expect(cleanupCount).toBe(0) }) })