import React, { useRef, useState } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import MagicPortal from 'react-magic-portal' describe('MagicPortal', () => { beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { vi.clearAllMocks() }) describe('Basic Functionality', () => { it('should render children in portal when anchor exists', () => { // Setup anchor element const anchor = document.createElement('div') anchor.id = 'test-anchor' document.body.appendChild(anchor) render(
Portal Content
) const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() expect(anchor.contains(portalContent)).toBe(true) }) it('should not render children when anchor does not exist', () => { render(
Portal Content
) expect(screen.queryByTestId('portal-content')).toBeNull() }) it('should render children when anchor appears later', async () => { function TestComponent() { const [showAnchor, setShowAnchor] = useState(false) return (
{showAnchor &&
Anchor
}
Portal Content
) } const user = userEvent.setup() render() expect(screen.queryByTestId('portal-content')).toBeNull() await user.click(screen.getByText('Show Anchor')) await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeTruthy() }) }) it('should clean up when anchor is removed', async () => { function TestComponent() { const [showAnchor, setShowAnchor] = useState(true) return (
{showAnchor &&
Anchor
}
Portal Content
) } const user = userEvent.setup() render() expect(screen.getByTestId('portal-content')).toBeTruthy() await user.click(screen.getByText('Hide Anchor')) await waitFor(() => { expect(screen.queryByTestId('portal-content')).toBeNull() }) }) }) describe('Anchor Types', () => { it('should work with CSS selector string', () => { const anchor = document.createElement('div') anchor.className = 'test-class' document.body.appendChild(anchor) render(
Portal Content
) const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() expect(anchor.contains(portalContent)).toBe(true) }) it('should work with element reference', () => { function TestComponent() { const anchorRef = useRef(null) return (
Anchor
Portal Content
) } render() const portalContent = screen.getByTestId('portal-content') const anchor = screen.getByTestId('anchor') expect(portalContent).toBeTruthy() expect(anchor.contains(portalContent)).toBe(true) }) it('should work with function returning element', () => { const anchor = document.createElement('div') anchor.id = 'function-anchor' document.body.appendChild(anchor) render( document.getElementById('function-anchor')}>
Portal Content
) const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() expect(anchor.contains(portalContent)).toBe(true) }) it('should work with direct element', () => { const anchor = document.createElement('div') document.body.appendChild(anchor) render(
Portal Content
) const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() expect(anchor.contains(portalContent)).toBe(true) }) it('should handle null anchor gracefully', () => { render(
Portal Content
) expect(screen.queryByTestId('portal-content')).toBeNull() }) }) describe('Position Options', () => { beforeEach(() => { const anchor = document.createElement('div') anchor.id = 'position-anchor' anchor.innerHTML = 'Existing Content' document.body.appendChild(anchor) }) it('should last by default', () => { render(
Portal Content
) const anchor = document.getElementById('position-anchor')! const portalContent = screen.getByTestId('portal-content') expect(anchor.contains(portalContent)).toBe(true) }) it('should first when position is first', () => { render(
Portal Content
) const anchor = document.getElementById('position-anchor')! const portalContent = screen.getByTestId('portal-content') expect(anchor.contains(portalContent)).toBe(true) }) it('should position before when position is before', () => { render(
Portal Content
) const anchor = document.getElementById('position-anchor')! const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() // For before position, content should be positioned before the anchor expect(anchor.parentElement!.contains(portalContent) || document.body.contains(portalContent)).toBe(true) }) it('should position after when position is after', () => { render(
Portal Content
) const anchor = document.getElementById('position-anchor')! const portalContent = screen.getByTestId('portal-content') expect(portalContent).toBeTruthy() // For after position, content should be positioned after the anchor expect(anchor.parentElement!.contains(portalContent) || document.body.contains(portalContent)).toBe(true) }) }) describe('Lifecycle Callbacks', () => { it('should call onMount when portal is mounted', async () => { const onMount = vi.fn() const anchor = document.createElement('div') anchor.id = 'mount-anchor' document.body.appendChild(anchor) render(
Portal Content
) await waitFor(() => { expect(onMount).toHaveBeenCalledTimes(1) expect(onMount).toHaveBeenCalledWith(anchor, expect.any(HTMLDivElement)) }) }) it('should call onUnmount when component is unmounted', async () => { const onUnmount = vi.fn() const anchor = document.createElement('div') anchor.id = 'unmount-anchor' document.body.appendChild(anchor) const { unmount } = render(
Portal Content
) expect(screen.getByTestId('portal-content')).toBeTruthy() unmount() expect(onUnmount).toHaveBeenCalledTimes(1) expect(onUnmount).toHaveBeenCalledWith(anchor, expect.any(HTMLDivElement)) }) it('should call both onMount and onUnmount during anchor changes', async () => { const onMount = vi.fn() const onUnmount = vi.fn() // Create anchor element that will persist const anchor = document.createElement('div') anchor.id = 'toggle-anchor' const { unmount } = render(
Portal Content
) // No anchor exists initially expect(screen.queryByTestId('portal-content')).toBeNull() expect(onMount).not.toHaveBeenCalled() // Add anchor to DOM document.body.appendChild(anchor) // Wait for portal to mount await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeTruthy() expect(onMount).toHaveBeenCalledTimes(1) }) // Unmount component to trigger onUnmount unmount() expect(onUnmount).toHaveBeenCalledTimes(1) }) }) describe('Portal Content Ref Handling', () => { it('should forward ref to portal content element', () => { const contentRef = vi.fn() const anchor = document.createElement('div') anchor.id = 'ref-anchor' document.body.appendChild(anchor) render(
Portal Content
) expect(contentRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)) expect(contentRef.mock.calls[0][0]?.textContent).toBe('Portal Content') }) it('should handle function refs on portal content', () => { let refElement: HTMLDivElement | null = null const refCallback = (element: HTMLDivElement | null) => { refElement = element } const anchor = document.createElement('div') anchor.id = 'func-ref-anchor' document.body.appendChild(anchor) render(
Portal Content
) expect(refElement).toBeInstanceOf(HTMLDivElement) expect(refElement!.textContent).toBe('Portal Content') }) it('should call ref callback with null when content unmounts', () => { const refCallback = vi.fn() const anchor = document.createElement('div') anchor.id = 'unmount-ref-anchor' document.body.appendChild(anchor) const { unmount } = render(
Portal Content
) expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLDivElement)) unmount() expect(refCallback).toHaveBeenLastCalledWith(null) }) it('should maintain content refs across position changes', async () => { const contentRef = vi.fn() const anchor = document.createElement('div') anchor.id = 'position-ref-anchor' document.body.appendChild(anchor) const { rerender } = render(
Portal Content
) expect(contentRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)) expect(screen.getByTestId('portal-content')).toBeTruthy() // Clear mock to track new calls contentRef.mockClear() rerender(
Portal Content
) // Content should be re-rendered with new position expect(contentRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)) expect(screen.getByTestId('portal-content')).toBeTruthy() }) it('should work with forwardRef components as portal content', () => { const ForwardedComponent = React.forwardRef( ({ children }, forwardedRef) => ( ) ) ForwardedComponent.displayName = 'ForwardedComponent' const refCalls: Array = [] const handleButtonRef: React.RefCallback = (element) => { refCalls.push(element) } const anchor = document.createElement('div') anchor.id = 'forward-ref-anchor' document.body.appendChild(anchor) render( Click me ) expect(refCalls[0]).toBeInstanceOf(HTMLButtonElement) expect(refCalls[0]?.textContent).toBe('Click me') }) it('should log error when multiple children are provided', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const anchor = document.createElement('div') anchor.id = 'multiple-children-anchor' document.body.appendChild(anchor) render( // @ts-expect-error - intentionally passing multiple children to assert runtime guard First element Second element ) expect(consoleErrorSpy).toHaveBeenCalledWith( '[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.' ) expect(anchor.children.length).toBe(0) consoleErrorSpy.mockRestore() }) it('should handle nested refs correctly', () => { const outerRef = vi.fn() const innerRef = vi.fn() const anchor = document.createElement('div') anchor.id = 'nested-ref-anchor' document.body.appendChild(anchor) render(
Nested content
) expect(outerRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)) expect(innerRef).toHaveBeenCalledWith(expect.any(HTMLSpanElement)) expect(screen.getByTestId('outer-element')).toBeTruthy() expect(screen.getByTestId('inner-element')).toBeTruthy() }) it('should handle ref updates when content changes', async () => { const ref1 = vi.fn() const ref2 = vi.fn() const anchor = document.createElement('div') anchor.id = 'dynamic-content-anchor' document.body.appendChild(anchor) function TestComponent() { const [showFirst, setShowFirst] = useState(true) return (
{showFirst ? (
First Content
) : ( Second Content )}
) } const user = userEvent.setup() render() expect(ref1).toHaveBeenCalledWith(expect.any(HTMLDivElement)) expect(screen.getByTestId('first-content')).toBeTruthy() // Toggle content await user.click(screen.getByText('Toggle Content')) // Wait for the content to change and verify second ref is called await waitFor(() => { expect(ref2).toHaveBeenCalledWith(expect.any(HTMLSpanElement)) expect(screen.getByTestId('second-content')).toBeTruthy() }) }) }) describe('Multiple Portals', () => { it('should support multiple portals on the same anchor', () => { const anchor = document.createElement('div') anchor.id = 'multi-anchor' document.body.appendChild(anchor) render(
Portal 1
Portal 2
) const portal1 = screen.getByTestId('portal-1') const portal2 = screen.getByTestId('portal-2') expect(portal1).toBeTruthy() expect(portal2).toBeTruthy() expect(anchor.contains(portal1)).toBe(true) expect(anchor.contains(portal2)).toBe(true) }) }) describe('Dynamic Content Updates', () => { it('should detect when matching elements are added to DOM', async () => { function TestComponent() { const [elementCount, setElementCount] = useState(0) const addElement = () => { const newElement = document.createElement('div') newElement.className = 'dynamic-target' newElement.textContent = `Target ${elementCount + 1}` document.body.appendChild(newElement) setElementCount((prev) => prev + 1) } return (
Portal Content
) } const user = userEvent.setup() render() expect(screen.queryByTestId('portal-content')).toBeNull() await user.click(screen.getByText('Add Target Element')) await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeTruthy() }) }) }) describe('Error Handling', () => { it('should handle invalid selectors gracefully', () => { expect(() => { render(
Portal Content
) }).not.toThrow() expect(screen.queryByTestId('portal-content')).toBeNull() }) it('should handle function that returns null', () => { render( null}>
Portal Content
) expect(screen.queryByTestId('portal-content')).toBeNull() }) it('should handle function that throws error', () => { const errorFunction = () => { throw new Error('Test error') } // The component should handle the error gracefully and not crash expect(() => { render(
Portal Content
) }).toThrow('Test error') // Portal content should not be rendered when anchor function throws expect(screen.queryByTestId('portal-content')).toBeNull() }) }) describe('Key Prop', () => { it('should pass key to ReactDOM.createPortal', () => { const anchor = document.createElement('div') anchor.id = 'key-anchor' document.body.appendChild(anchor) const { rerender } = render(
Portal Content 1
) expect(screen.getByTestId('portal-content-1')).toBeTruthy() rerender(
Portal Content 2
) expect(screen.queryByTestId('portal-content-1')).toBeNull() expect(screen.getByTestId('portal-content-2')).toBeTruthy() }) }) describe('Text Node Handling', () => { it('should not render Fragment children', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const anchor = document.createElement('div') anchor.id = 'fragment-anchor' document.body.appendChild(anchor) render( <>
Child 1
Child 2
) // Should log error about Fragment not being supported expect(consoleErrorSpy).toHaveBeenCalledWith( '[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.' ) // Anchor should remain empty since Fragment is not supported expect(anchor.textContent).toBe('') expect(anchor.children.length).toBe(0) consoleErrorSpy.mockRestore() }) it('should not render pure text content', () => { const anchor = document.createElement('div') anchor.id = 'text-only-anchor' document.body.appendChild(anchor) render( // @ts-expect-error - testing that text nodes are not rendered Just plain text ) // Anchor should remain empty since text nodes are not rendered expect(anchor.textContent).toBe('') expect(anchor.children.length).toBe(0) }) it('should not render null children', () => { const anchor = document.createElement('div') anchor.id = 'null-anchor' document.body.appendChild(anchor) render({null}) // Anchor should remain empty since null is not rendered expect(anchor.textContent).toBe('') expect(anchor.children.length).toBe(0) }) }) describe('Cleanup', () => { it('should clean up MutationObserver on unmount', () => { const anchor = document.createElement('div') anchor.id = 'cleanup-anchor' document.body.appendChild(anchor) const { unmount } = render(
Portal Content
) expect(screen.getByTestId('portal-content')).toBeTruthy() expect(() => unmount()).not.toThrow() expect(screen.queryByTestId('portal-content')).toBeNull() }) }) it('should call ref cleanup function on unmount', () => { const cleanupFn = vi.fn() // React 19: Cleanup functions for refs const customRef = () => () => cleanupFn() const anchor = document.createElement('div') anchor.id = 'cleanup-ref-anchor' document.body.appendChild(anchor) const { unmount } = render(
Portal Content
) expect(screen.getByTestId('portal-content')).toBeTruthy() unmount() expect(cleanupFn).toHaveBeenCalledTimes(1) }) })