import { describe, it, expect } from 'vitest' import type { Handle } from '@remix-run/component' import { dom } from '@remix-run/events' import { flushRenderQueue as flush } from '../reconcile.ts' import { render } from '../render.ts' describe('ref prop', () => { it('calls after entire DOM tree is stable', () => { let element = document.createElement('div') let refCallOrder: string[] = [] let parentElements: Array = [] function App() { return (
{ refCallOrder.push('first-child') parentElements.push(node.parentElement) }} > First child
{ refCallOrder.push('second-child') parentElements.push(node.parentElement) // Should be able to access sibling let sibling = node.parentElement?.querySelector('div:first-child') refCallOrder.push(sibling ? 'sibling-found' : 'sibling-not-found') }} > Second child
) } render(, element) // All refs should have been called expect(refCallOrder).toEqual(['first-child', 'second-child', 'sibling-found']) // All elements should have had their parent available expect(parentElements.every((parent) => parent !== null)).toBe(true) // Parent should be the containing div expect(parentElements[0]?.tagName).toBe('DIV') expect(parentElements[1]?.tagName).toBe('DIV') }) it('can access complete layout information', () => { let element = document.createElement('div') let layoutInfo: any[] = [] function App() { return (
{ layoutInfo.push({ hasParent: !!node.parentElement, parentTagName: node.parentElement?.tagName, nextSibling: !!node.nextElementSibling, previousSibling: !!node.previousElementSibling, }) }} > First { layoutInfo.push({ hasParent: !!node.parentElement, parentTagName: node.parentElement?.tagName, nextSibling: !!node.nextElementSibling, previousSibling: !!node.previousElementSibling, }) }} > Second
) } render(, element) expect(layoutInfo).toEqual([ { hasParent: true, parentTagName: 'DIV', nextSibling: true, // Second span exists previousSibling: false, }, { hasParent: true, parentTagName: 'DIV', nextSibling: false, previousSibling: true, // First span exists }, ]) }) it('is only called on unmount, not on updates', () => { let element = document.createElement('div') let refCalls: Array<{ type: string element: HTMLElement | null value?: string }> = [] let refCallback = (node: HTMLElement) => { refCalls.push({ type: node ? 'set' : 'cleanup', element: node, value: node?.textContent || undefined, }) } function App({ value }: { value: string }) { return
{value}
} // Initial mount let root = render(, element) expect(refCalls).toEqual([{ type: 'set', element: expect.any(HTMLElement), value: 'first' }]) refCalls.length = 0 // Clear for next test // Update with same ref - should NOT call ref again root.update() expect(refCalls).toEqual([]) // No ref calls on update expect(element.textContent).toBe('second') // But content was updated // Unmount by mounting different component function DifferentApp() { return Different content } root.update() // Should NOT have called ref with null - refs that don't return cleanup functions // are never called again after the initial mount expect(refCalls).toEqual([]) }) it('is never called again when ref function changes', () => { let element = document.createElement('div') let refCalls: Array<{ id: string type: string element: HTMLElement | null }> = [] let refCallback1 = (node: HTMLElement) => { refCalls.push({ id: 'ref1', type: node ? 'set' : 'cleanup', element: node, }) } let refCallback2 = (node: HTMLElement) => { refCalls.push({ id: 'ref2', type: node ? 'set' : 'cleanup', element: node, }) } function App({ useRef1 }: { useRef1: boolean }) { return
Content
} // Initial mount with ref1 let root = render(, element) expect(refCalls).toEqual([{ id: 'ref1', type: 'set', element: expect.any(HTMLElement) }]) refCalls.length = 0 // Change to ref2 - should NOT call either ref (element stays the same) root.update() expect(refCalls).toEqual([]) // No ref calls when function changes but element persists // Unmount - should call cleanup with original ref function function DifferentApp() { return Different } root.update() // Since ref1 doesn't return a cleanup function, it should never be called again expect(refCalls).toEqual([]) }) it('calls cleanup when element is conditionally removed from render tree', () => { let element = document.createElement('div') let refCalls: Array<{ type: string; element: HTMLElement | null }> = [] let refWithCleanup = (node: HTMLElement) => { refCalls.push({ type: node ? 'set' : 'cleanup', element: node, }) } function Subject(this: Handle) { let state = 0 return () => { return (
{state < 2 &&
}
) } } // Initial mount - div should be present, ref should be called render(, element) expect(refCalls).toEqual([{ type: 'set', element: expect.any(HTMLElement) }]) expect(element.querySelector('[data-testid="conditional-div"]')).toBeTruthy() refCalls.length = 0 // Clear for next test // First click (state = 1) - div should still be present, no ref calls let button = element.querySelector('[data-testid="increment-button"]') as HTMLElement button.click() flush() // Wait for async render to complete expect(refCalls).toEqual([]) // No ref calls on update expect(element.querySelector('[data-testid="conditional-div"]')).toBeTruthy() // Second click (state = 2) - div should be removed, ref cleanup should be called button.click() flush() // Wait for async render to complete // Should NOT have called ref with null - refs that don't return cleanup functions // are never called again after the initial mount expect(refCalls).toEqual([]) expect(element.querySelector('[data-testid="conditional-div"]')).toBeFalsy() }) it('calls only once on mount, not on every render', () => { let element = document.createElement('div') let refCalls: Array<{ type: string element: HTMLElement | null renderCount: number }> = [] let refCallback = (node: HTMLElement) => { refCalls.push({ type: node ? 'set' : 'cleanup', element: node, renderCount: renderCount, }) } let renderCount = 0 function TestComponent(this: Handle) { let state = 0 return () => { renderCount++ return (
Content {state}
) } } // Initial mount - ref should be called once render(, element) flush() expect(refCalls).toEqual([{ type: 'set', element: expect.any(HTMLElement), renderCount: 1 }]) expect(renderCount).toBe(1) refCalls.length = 0 // Clear for next test // Multiple re-renders - ref should NOT be called again let button = element.querySelector('[data-testid="update-button"]') as HTMLElement button.click() flush() expect(renderCount).toBe(2) expect(refCalls).toEqual([]) // No ref calls on re-render button.click() flush() expect(renderCount).toBe(3) expect(refCalls).toEqual([]) // Still no ref calls button.click() flush() expect(renderCount).toBe(4) expect(refCalls).toEqual([]) // Still no ref calls // Unmount - ref should be called with null function DifferentComponent() { return Different } render(, element) flush() // Since this ref doesn't return a cleanup function, it should never be called again expect(refCalls).toEqual([]) }) it('calls only once even when ref function recreates on every render', () => { let element = document.createElement('div') let refSetCalls = 0 let refCleanupCalls = 0 let refSetId: string | null = null let refCleanupId: string | null = null function TestComponent(this: Handle) { let state = 0 return () => { const renderId = `render-${state}` return (
{ // This creates a NEW function on every render, but should only be called once! if (node) { refSetCalls++ refSetId = renderId } else { refCleanupCalls++ refCleanupId = renderId } }} data-testid="ref-element" > Content {state}
) } } // Initial mount - ref should be called once render(, element) flush() expect(refSetCalls).toBe(1) expect(refSetId).toBe('render-0') // Called with initial render expect(refCleanupCalls).toBe(0) // Re-renders - ref should NOT be called again, even with new functions let button = element.querySelector('[data-testid="update-button"]') as HTMLElement button.click() flush() expect(refSetCalls).toBe(1) // Still only 1 set call expect(refCleanupCalls).toBe(0) // No cleanup calls button.click() flush() expect(refSetCalls).toBe(1) // Still only 1 set call expect(refCleanupCalls).toBe(0) // No cleanup calls // Unmount - ref cleanup should be called with original function function DifferentComponent() { return Different } render(, element) flush() expect(refSetCalls).toBe(1) // Still only 1 set call expect(refCleanupCalls).toBe(0) // No cleanup calls since ref doesn't return a cleanup function expect(refCleanupId).toBe(null) // Cleanup never called }) })