import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render } from '../render.ts'
import { flushRenderQueue as flush } from '../reconcile.ts'
import { dom } from '@remix-run/events'
import type { RemixNode, Handle } from '@remix-run/component'
import { Catch, Fragment } from '@remix-run/component'
describe('component integration', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('components update with this.render()', () => {
function Counter(this: Handle) {
let count = 0
return () => (
)
}
let element = document.createElement('div')
render(, element)
expect(element.innerHTML).toContain('Count: 0')
let button = element.querySelector('button')
button!.dispatchEvent(new Event('click'))
flush()
expect(element.innerHTML).toContain('Count: 1')
})
it('warns when calling this.render() from a non-stateful component', () => {
let element = document.createElement('div')
let warns: string[] = []
vi.spyOn(console, 'warn').mockImplementation((msg: any) => {
warns.push(String(msg))
})
function NonStateful(this: Handle) {
// Simple component returning JSX; calling this.render() from an event handler should warn
return (
)
}
render(, element)
let btn = element.querySelector('#ns-btn') as HTMLButtonElement
btn.click()
flush()
expect(warns.length).toBe(1)
expect(warns[0]).toMatch('this.render() was called from non-stateful component')
})
it('maintains stable this.id across re-renders', () => {
let element = document.createElement('div')
let componentIds: string[] = []
function TestComponent(this: Handle) {
let count = 0
let componentId = this.id // Store ID in setup
return () => {
// Track ID usage in render function
componentIds.push(componentId)
return (
ID: {componentId}
)
}
}
render(, element)
let firstId = componentIds[0]
expect(element.innerHTML).toContain(`ID: ${firstId}`)
let button = element.querySelector('button')
button!.click()
flush()
button!.click()
flush()
// ID should be the same across re-renders
expect(componentIds).toEqual([firstId, firstId, firstId])
expect(element.innerHTML).toContain(`ID: ${firstId}`)
})
it('primitive rendering', () => {
let element = document.createElement('div')
function TestPrimitives() {
return (
String: {'hello'}
Number: {42}
Boolean: {true}
Null: {null}
Undefined: {undefined}
Zero: {0}
Empty: {''}
)
}
render(, element)
expect(element.innerHTML).toContain('String: hello')
expect(element.innerHTML).toContain('Number: 42')
// Booleans render nothing
expect(element.innerHTML).toContain('Boolean: ')
expect(element.innerHTML).toContain('Null: ')
expect(element.innerHTML).toContain('Undefined: ')
expect(element.innerHTML).toContain('Zero: 0')
expect(element.innerHTML).toContain('Empty: ')
})
it('complex state interactions', () => {
let element = document.createElement('div')
function Calculator(this: Handle) {
let value = 0
let history: number[] = []
let add = (n: number) => {
value += n
history.push(value)
this.render()
}
return () => (
Value: {value}
History: {history.join(', ')}
)
}
render(, element)
expect(element.innerHTML).toContain('Value: 0')
expect(element.innerHTML).toContain('History: ')
let addOneBtn = element.querySelector('button')
let addFiveBtn = element.querySelectorAll('button')[1]
let resetBtn = element.querySelectorAll('button')[2]
addOneBtn!.click()
flush()
expect(element.innerHTML).toContain('Value: 1')
expect(element.innerHTML).toContain('History: 1')
addFiveBtn!.click()
flush()
expect(element.innerHTML).toContain('Value: 6')
expect(element.innerHTML).toContain('History: 1, 6')
resetBtn!.click()
flush()
expect(element.innerHTML).toContain('Value: 0')
expect(element.innerHTML).toContain('History: ')
})
it('component unmounting with cleanup', () => {
let element = document.createElement('div')
let cleanupCalled = false
function ConditionalChild(this: Handle) {
this.onCleanup(() => {
cleanupCalled = true
})
return () =>
Child content
}
function Parent(this: Handle) {
let showChild = true
return () => (
{showChild && }
)
}
render(, element)
expect(element.innerHTML).toContain('Child content')
expect(cleanupCalled).toBe(false)
// Click toggle to hide child - this should trigger cleanup
let button = element.querySelector('button')
button!.click()
flush()
expect(element.innerHTML).not.toContain('Child content')
expect(cleanupCalled).toBe(true)
})
it('deep component nesting', () => {
let element = document.createElement('div')
function Level3() {
return Level 3
}
function Level2() {
return (
}
let subject = (
)
let element = document.createElement('div')
render(subject, element)
expect(element.innerHTML).toContain('Child')
})
it('children handling - returning single child directly', () => {
function Parent({ children }: { children: RemixNode }) {
return children
}
function Child() {
return
Child
}
let element = document.createElement('div')
render(
,
element,
)
expect(element.innerHTML).toContain('Child')
})
it('children handling - returning multiple children directly', () => {
function Parent({ children }: { children: RemixNode }) {
return children
}
function Child({ id }: { id: string }) {
return
Child {id}
}
let element = document.createElement('div')
render(
,
element,
)
expect(element.innerHTML).toContain('Child 1')
expect(element.innerHTML).toContain('Child 2')
})
it('JSX fragments', () => {
let element = document.createElement('div')
function TestFragments() {
return (
<>
First paragraph
Second paragraph
A span
>
)
}
render(, element)
expect(element.innerHTML).toContain('
First paragraph
')
expect(element.innerHTML).toContain('
Second paragraph
')
expect(element.innerHTML).toContain('A span')
// Should not have a wrapper element
expect(element.children.length).toBe(3) // Direct children
expect(element.children[0].tagName).toBe('P')
expect(element.children[1].tagName).toBe('P')
expect(element.children[2].tagName).toBe('SPAN')
})
it('Fragment with conditional content', () => {
let element = document.createElement('div')
function ConditionalFragment(this: Handle) {
let showSecond = true
return () => (
<>
Always shown
{showSecond &&
Conditionally shown
}
>
)
}
render(, element)
expect(element.innerHTML).toContain('Always shown')
expect(element.innerHTML).toContain('Conditionally shown')
expect(element.children.length).toBe(3) // p, p, button
let button = element.querySelector('button')
button!.click()
flush()
expect(element.innerHTML).toContain('Always shown')
expect(element.innerHTML).not.toContain('Conditionally shown')
expect(element.children.length).toBe(2) // p, button
})
it('properly flattens JSX arrays from map() calls', () => {
let element = document.createElement('div')
function TestNestedArrays() {
let items = ['Item 1', 'Item 2', 'Item 3']
return (
{items.map((item, index) => (
{item}
))}
)
}
render(, element)
// Should not render [object Object] strings - that would indicate the arrays weren't flattened
expect(element.innerHTML).not.toContain('[object Object]')
// Should properly render all the mapped elements
expect(element.innerHTML).toContain('')
expect(element.innerHTML).toContain('
Item 1
')
expect(element.innerHTML).toContain('
Item 2
')
expect(element.innerHTML).toContain('
Item 3
')
// Should have the correct DOM structure
expect(element.children.length).toBe(1) // The wrapper div
let wrapper = element.children[0]
expect(wrapper.children.length).toBe(4) // style + 3 paragraphs
expect(wrapper.children[0].tagName).toBe('STYLE')
expect(wrapper.children[1].tagName).toBe('P')
expect(wrapper.children[2].tagName).toBe('P')
expect(wrapper.children[3].tagName).toBe('P')
})
it('lifts head-managed tags and keeps DFS order with updates', () => {
let element = document.createElement('div')
// Ensure a clean for deterministic assertions
let cleanHead = document.head
while (cleanHead.firstChild) cleanHead.removeChild(cleanHead.firstChild)
function Child({ id }: { id: number }) {
return (
<>
>
)
}
function App() {
return (
FirstSecond
)
}
let root = render(, element)
let head = document.head
// Expect order: runtime styles (if any), then First title, child1 meta, child1 link, Second title, child2 meta, child2 link
let titles = head.querySelectorAll('title')
expect(titles.length).toBe(2)
expect(titles[0].textContent).toBe('First')
expect(titles[1].textContent).toBe('Second')
let meta1 = head.querySelector('meta[name="child-1"][content="v1"]')
let meta2 = head.querySelector('meta[name="child-2"][content="v2"]')
expect(meta1).toBeTruthy()
expect(meta2).toBeTruthy()
// Update: re-render app with changed title text and changed meta content for child 1
function AppUpdated() {
return (
First-UpdatedSecond
)
}
root.update()
titles = head.querySelectorAll('title')
expect(titles.length).toBe(2)
expect(titles[0].textContent).toBe('First-Updated')
expect(titles[1].textContent).toBe('Second')
})
it('render ordering and batching - child components render after parents', async () => {
let element = document.createElement('div')
let childRenderLog: number[] = []
let parentCounter = 0
function Child(this: Handle, initialProps: { counter: number }) {
// Set up event listener only once
let listenerSetup = false
this.afterRender(() => {
if (!listenerSetup) {
listenerSetup = true
document.addEventListener('test-event', () => {
this.render() // Child renders FIRST
})
}
})
return (props: { counter: number }) => {
// Read counter from the current render props, not from setup props
const { counter } = props
// Track what counter value the child renders with
childRenderLog.push(counter)
return
Child counter: {counter}
}
}
function Parent(this: Handle) {
let listenerSetup = false
this.afterRender(() => {
if (!listenerSetup) {
listenerSetup = true
document.addEventListener('test-event', () => {
// Simulate some state change that should affect child props
parentCounter++
this.render() // Parent renders SECOND
})
}
})
return () => {
return
}
}
render(, element)
// Initial render - child should see counter = 0
expect(childRenderLog).toEqual([0])
expect(element.innerHTML).toContain('Child counter: 0')
// Reset logs to focus on the race condition
childRenderLog = []
// Trigger the event - this is where the race condition happens
document.dispatchEvent(new CustomEvent('test-event'))
// Wait for asynchronous render queue to process
await new Promise((resolve) => setTimeout(resolve, 0))
// With proper batching, the child should ONLY render with the updated counter value (1)
// It should NOT render with the stale value (0) first
expect(childRenderLog).toEqual([1]) // Only renders with updated props
expect(element.innerHTML).toContain('Child counter: 1') // Final state should be correct
})
})
describe('context system integration', () => {
it('context system - parent to child', () => {
let element = document.createElement('div')
type ThemeContext = { color: string; size: number }
function ThemeProvider(this: Handle, { children }: { children: any }) {
this.context.set({ color: 'blue', size: 16 })
return children
}
function ThemedText(this: Handle) {
let theme = this.context.get(ThemeProvider)
return
Themed!
}
function App() {
return (
}
/>
)
}
render(, element)
expect(element.innerHTML).toContain('style="color: blue; font-size: 16px;"')
expect(element.innerHTML).toContain('Themed!')
})
it('context system - nested providers', () => {
let element = document.createElement('div')
type ThemeContext = { color: string }
function ThemeProvider(
this: Handle,
{ color, children }: { color: string; children: any },
) {
this.context.set({ color })
return children
}
function ThemedText(this: Handle) {
let theme = this.context.get(ThemeProvider)
return {theme.color}
}
function App() {
return (
} />
}
/>
)
}
render(, element)
expect(element.innerHTML).toContain('red')
expect(element.innerHTML).toContain('green')
})
it('context consumers re-render when provider re-renders', () => {
let element = document.createElement('div')
let providerRenderCount = 0
let consumer1RenderCount = 0
let consumer2RenderCount = 0
type CounterContext = {
count: number
increment: () => void
}
function CounterProvider(this: Handle, { children }: { children: RemixNode }) {
let count = 0
let increment = () => {
count++
this.render() // This should trigger consumer re-renders
}
return () => {
providerRenderCount++
// Set context during render (like Tabs example)
this.context.set({ count, increment })
return
{children}
}
}
function Consumer1(this: Handle) {
return () => {
consumer1RenderCount++
let { count } = this.context.get(CounterProvider)
return
Count: {count}
}
}
function Consumer2(this: Handle) {
return () => {
consumer2RenderCount++
let { count, increment } = this.context.get(CounterProvider)
return (
)
}
}
function App() {
return (
)
}
render(, element)
// Initial render counts
expect(providerRenderCount).toBe(1)
expect(consumer1RenderCount).toBe(1)
expect(consumer2RenderCount).toBe(1)
// Initial state
expect(element.querySelector('#consumer1')?.textContent).toBe('Count: 0')
expect(element.querySelector('#consumer2')?.textContent).toBe('Increment 0')
// Trigger increment via consumer2 button
let incrementBtn = element.querySelector('#consumer2') as HTMLButtonElement
incrementBtn.click()
flush()
// Provider should re-render (called this.render())
expect(providerRenderCount).toBe(2)
// Consumers should also re-render (they consumed the context)
expect(consumer1RenderCount).toBe(2)
expect(consumer2RenderCount).toBe(2)
// Verify updated content
expect(element.querySelector('#consumer1')?.textContent).toBe('Count: 1')
expect(element.querySelector('#consumer2')?.textContent).toBe('Increment 1')
})
})
describe('error boundaries integration', () => {
it(' renders multiple children correctly', () => {
let element = document.createElement('div')
render(
error}>
child 1
child 2
,
element,
)
// Expect three sibling nodes with no wrapper element
expect(element.innerHTML).toBe('
child 1
child 2
')
})
it(' renders children when no error', () => {
let element = document.createElement('div')
function App() {
return (
')
expect(element.innerHTML).toContain('Safe content')
})
it(' shows fallback when descendant throws during setup', () => {
let element = document.createElement('div')
function ThrowDuringSetup(this: Handle) {
throw new Error('Setup error')
}
function App() {
return (
')
expect(element.innerHTML).toContain('Error: Setup error')
expect(element.innerHTML).not.toContain('Before error')
expect(element.innerHTML).not.toContain('After error')
})
it(' shows fallback when descendant throws during render', () => {
let element = document.createElement('div')
function ThrowDuringRender() {
return () => {
throw new Error('Render error')
}
}
function App() {
return (
App
Error: {error.message}
}>
Before error
After error
)
}
render(, element)
// Sibling should render immediately
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('Error: Render error')
expect(element.innerHTML).not.toContain('Before error')
expect(element.innerHTML).not.toContain('After error')
})
it(' shows fallback when descendant throws during re-render', () => {
let element = document.createElement('div')
function KaboomComponent(this: Handle) {
let count = 0
return () => {
if (count > 3) {
throw new Error('Kaboom!')
}
return (
)
}
}
function App() {
return (
App
Error: {error.message}
}>
Before kaboom
After kaboom
)
}
render(, element)
// Initially should render successfully
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('Before kaboom')
expect(element.innerHTML).toContain('Count: 0')
expect(element.innerHTML).toContain('After kaboom')
expect(element.innerHTML).not.toContain('Error: Kaboom!')
// Click button multiple times to trigger re-renders
let button = element.querySelector('button')!
// Click 1: count = 1, still fine
button.click()
flush()
expect(element.innerHTML).toContain('Count: 1')
expect(element.innerHTML).not.toContain('Error: Kaboom!')
// Click 2: count = 2, still fine
button.click()
flush()
expect(element.innerHTML).toContain('Count: 2')
expect(element.innerHTML).not.toContain('Error: Kaboom!')
// Click 3: count = 3, still fine
button.click()
flush()
expect(element.innerHTML).toContain('Count: 3')
expect(element.innerHTML).not.toContain('Error: Kaboom!')
// Click 4: count = 4, should throw and show error boundary
button.click()
flush()
// After error, should show fallback instead of component content
expect(element.innerHTML).toContain('
App
') // Sibling still renders
expect(element.innerHTML).toContain('Error: Kaboom!')
expect(element.innerHTML).not.toContain('Before kaboom')
expect(element.innerHTML).not.toContain('Count:')
expect(element.innerHTML).not.toContain('After kaboom')
})
it(' shows fallback when action throws', async () => {
let element = document.createElement('div')
function FormComponent(this: Handle) {
let save = this.action(async () => {
throw new Error('Save failed!')
})
// Trigger the action immediately to test error handling
setTimeout(() => save(), 0)
return () =>
Save component
}
function App() {
return (
App
Error: {error.message}
}>
Form container
)
}
render(, element)
// Initial render should work
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('Form container')
expect(element.innerHTML).toContain('Save component')
// Wait for async action to complete
await new Promise((resolve) => setTimeout(resolve, 0))
flush()
// After error, should show fallback instead of form content
expect(element.innerHTML).toContain('
App
') // Sibling still renders
expect(element.innerHTML).toContain('Error: Save failed!')
expect(element.innerHTML).not.toContain('Form container')
expect(element.innerHTML).not.toContain('Save component')
})
it(' shows fallback when action throws with nested boundaries', async () => {
let element = document.createElement('div')
function InnerFormComponent(this: Handle) {
let save = this.action(async () => {
throw new Error('Inner save failed!')
})
return () => (
)
}
function OuterFormComponent(this: Handle) {
let save = this.action(async () => {
throw new Error('Outer save failed!')
})
return () => (
Inner Error: {error.message}
}>
)
}
function App() {
return (
App
Outer Error: {error.message}
}>
)
}
let root = render(, element)
// Test inner form error goes to inner boundary
let innerForm = element.querySelector('#inner-form') as HTMLFormElement
let innerSubmitEvent = new Event('submit', { bubbles: true, cancelable: true })
innerForm.dispatchEvent(innerSubmitEvent)
await new Promise((resolve) => setTimeout(resolve, 0))
flush()
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('Inner Error: Inner save failed!')
expect(element.innerHTML).toContain('') // Outer form still there
expect(element.innerHTML).not.toContain('') // Inner form replaced
// Reset for outer form test
root.update()
// Test outer form error goes to outer boundary
let outerForm = element.querySelector('#outer-form') as HTMLFormElement
let outerSubmitEvent = new Event('submit', { bubbles: true, cancelable: true })
outerForm.dispatchEvent(outerSubmitEvent)
await new Promise((resolve) => setTimeout(resolve, 0))
flush()
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('Outer Error: Outer save failed!')
expect(element.innerHTML).not.toContain('') // Outer form replaced
expect(element.innerHTML).not.toContain('') // Inner form also gone
})
it('action ignores aborted operations', async () => {
let element = document.createElement('div')
let actionCalls = 0
function FormComponent(this: Handle) {
let save = this.action(async (signal: AbortSignal) => {
actionCalls++
await new Promise((resolve) => setTimeout(resolve, 10))
// Don't throw if aborted - just return
if (signal.aborted) return
// This should not be reached because the second action should abort the first
})
return () => (
)
}
function App() {
return (
App
Error: {error.message}
}>
)
}
render(, element)
let btn1 = element.querySelector('#save1') as HTMLButtonElement
let btn2 = element.querySelector('#save2') as HTMLButtonElement
// Start first action
btn1.click()
// Immediately start second action (should abort first)
btn2.click()
await new Promise((resolve) => setTimeout(resolve, 20))
flush()
// Should have called action twice but no error boundary triggered
expect(actionCalls).toBe(2)
expect(element.innerHTML).toContain('
App
')
expect(element.innerHTML).toContain('')
expect(element.innerHTML).not.toContain('Error:')
})
it(' fallback with events works correctly', async () => {
let element = document.createElement('div')
let retryClicked = false
function FormComponent(this: Handle) {
let save = this.action(async () => {
throw new Error('Save failed!')
})
return () => (
)
}
function App() {
return (
App
(
Error: {error.message}
)}
>
)
}
render(, element)
let form = element.querySelector('form') as HTMLFormElement
let submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
await new Promise((resolve) => setTimeout(resolve, 0))
flush()
// Should show fallback with retry button
expect(element.innerHTML).toContain('Error: Save failed!')
expect(element.innerHTML).toContain('')
// Click retry button to test that events work in fallback
let retryBtn = element.querySelector('#retry') as HTMLButtonElement
retryBtn.click()
expect(retryClicked).toBe(true)
})
})
describe('Fragment integration', () => {
it(' renders children without wrapper', () => {
let element = document.createElement('div')
function App() {
return (