import {renderHook, act} from '@testing-library/react' import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest' import {useViewTransitions} from './useViewTransitions' // Mock react-router const mockNavigationType = vi.fn() const mockLocation = {pathname: '/test'} vi.mock('react-router', () => ({ useNavigationType: () => mockNavigationType(), useLocation: () => mockLocation, })) describe('useViewTransitions', () => { let originalStartViewTransition: any let addEventListenerSpy: ReturnType let removeEventListenerSpy: ReturnType let setAttributeSpy: ReturnType let removeAttributeSpy: ReturnType beforeEach(() => { vi.clearAllMocks() originalStartViewTransition = document.startViewTransition // Reset mock navigation type mockNavigationType.mockReturnValue('PUSH') // Setup spies addEventListenerSpy = vi.spyOn(window, 'addEventListener') removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') setAttributeSpy = vi.spyOn(document.documentElement, 'setAttribute') removeAttributeSpy = vi.spyOn(document.documentElement, 'removeAttribute') }) afterEach(() => { document.startViewTransition = originalStartViewTransition document.documentElement.removeAttribute('data-navigation-type') addEventListenerSpy.mockRestore() removeEventListenerSpy.mockRestore() setAttributeSpy.mockRestore() removeAttributeSpy.mockRestore() }) describe('Event Listeners', () => { it('registers event listeners on mount', () => { renderHook(() => useViewTransitions()) expect(addEventListenerSpy).toHaveBeenCalledWith( 'androidbackpressed', expect.any(Function) ) expect(addEventListenerSpy).toHaveBeenCalledWith( 'popstate', expect.any(Function) ) }) it('removes event listeners on unmount', () => { const {unmount} = renderHook(() => useViewTransitions()) unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith( 'popstate', expect.any(Function) ) expect(removeEventListenerSpy).toHaveBeenCalledWith( 'androidbackpressed', expect.any(Function) ) }) }) describe('Navigation Type Attribute', () => { it('sets forward navigation type for PUSH', () => { mockNavigationType.mockReturnValue('PUSH') renderHook(() => useViewTransitions()) expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'forward' ) }) it('sets backward navigation type for POP', () => { mockNavigationType.mockReturnValue('POP') renderHook(() => useViewTransitions()) expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'backward' ) }) it('does not set attribute for REPLACE navigation', () => { mockNavigationType.mockReturnValue('REPLACE') renderHook(() => useViewTransitions()) expect(setAttributeSpy).not.toHaveBeenCalledWith( 'data-navigation-type', expect.any(String) ) }) it('does not override existing navigation type attribute', () => { // Set existing attribute document.documentElement.setAttribute('data-navigation-type', 'existing') setAttributeSpy.mockClear() mockNavigationType.mockReturnValue('PUSH') renderHook(() => useViewTransitions()) // Should not set attribute again expect(setAttributeSpy).not.toHaveBeenCalledWith( 'data-navigation-type', 'forward' ) }) it('removes navigation type attribute on unmount', () => { const {unmount} = renderHook(() => useViewTransitions()) unmount() expect(removeAttributeSpy).toHaveBeenCalledWith('data-navigation-type') }) }) describe('Android Back Press', () => { beforeEach(() => { // Mock startViewTransition document.startViewTransition = vi.fn((callback: () => void) => { callback() return { finished: Promise.resolve(), ready: Promise.resolve(), types: new Set(), updateCallbackDone: Promise.resolve(), skipTransition: () => {}, } }) as any }) it('handles android back press with view transition', async () => { renderHook(() => useViewTransitions()) // Get the registered handler const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener expect(androidBackHandler).toBeDefined() // Trigger android back press await act(async () => { androidBackHandler(new Event('androidbackpressed')) }) expect(document.startViewTransition).toHaveBeenCalled() expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'backward' ) }) it('removes attribute after android back transition completes', async () => { const transitionPromise = Promise.resolve() const mockTransition = { finished: transitionPromise, ready: Promise.resolve(), types: new Set(), updateCallbackDone: Promise.resolve(), skipTransition: () => {}, } document.startViewTransition = vi.fn((callback: () => void) => { callback() return mockTransition }) as any renderHook(() => useViewTransitions()) const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener await act(async () => { androidBackHandler(new Event('androidbackpressed')) await transitionPromise }) expect(removeAttributeSpy).toHaveBeenCalledWith('data-navigation-type') }) it('handles view transition error on android back press', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) const errorTransition = { finished: Promise.reject(new Error('Transition failed')), ready: Promise.resolve(), types: new Set(), updateCallbackDone: Promise.resolve(), skipTransition: () => {}, } document.startViewTransition = vi.fn((callback: () => void) => { callback() return errorTransition }) as any renderHook(() => useViewTransitions()) const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener await act(async () => { androidBackHandler(new Event('androidbackpressed')) try { await errorTransition.finished } catch { // Expected error } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'View transition error:', expect.any(Error) ) consoleErrorSpy.mockRestore() }) it('handles android back press without view transition support', () => { // Remove startViewTransition ;(document as any).startViewTransition = undefined renderHook(() => useViewTransitions()) const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener // Should not throw expect(() => { androidBackHandler(new Event('androidbackpressed')) }).not.toThrow() }) }) describe('Popstate Event', () => { it('sets none navigation type for iOS back gesture', () => { renderHook(() => useViewTransitions()) const popstateHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'popstate' )?.[1] as EventListener expect(popstateHandler).toBeDefined() // Create popstate event with hasUAVisualTransition const popstateEvent = new PopStateEvent('popstate', {state: null}) ;(popstateEvent as any).hasUAVisualTransition = true act(() => { popstateHandler(popstateEvent) }) expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'none' ) }) it('does not set attribute for popstate without iOS gesture', () => { renderHook(() => useViewTransitions()) const popstateHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'popstate' )?.[1] as EventListener setAttributeSpy.mockClear() // Regular popstate without hasUAVisualTransition const popstateEvent = new PopStateEvent('popstate', {state: null}) act(() => { popstateHandler(popstateEvent) }) expect(setAttributeSpy).not.toHaveBeenCalledWith( 'data-navigation-type', 'none' ) }) it('does not set none type after android back press', () => { document.startViewTransition = vi.fn((callback: () => void) => { callback() return { finished: Promise.resolve(), ready: Promise.resolve(), types: new Set(), updateCallbackDone: Promise.resolve(), skipTransition: () => {}, } }) as any renderHook(() => useViewTransitions()) const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener const popstateHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'popstate' )?.[1] as EventListener // First trigger android back act(() => { androidBackHandler(new Event('androidbackpressed')) }) setAttributeSpy.mockClear() // Then trigger popstate with iOS gesture const popstateEvent = new PopStateEvent('popstate', {state: null}) ;(popstateEvent as any).hasUAVisualTransition = true act(() => { popstateHandler(popstateEvent) }) // Should not set none type because android back was pressed expect(setAttributeSpy).not.toHaveBeenCalledWith( 'data-navigation-type', 'none' ) }) }) describe('Navigation Type Changes', () => { it('updates attribute when navigation type changes', () => { mockNavigationType.mockReturnValue('PUSH') const {rerender} = renderHook(() => useViewTransitions()) expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'forward' ) setAttributeSpy.mockClear() // Change navigation type mockNavigationType.mockReturnValue('POP') rerender() expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'backward' ) }) it('removes and re-adds attribute on navigation type change', () => { mockNavigationType.mockReturnValue('PUSH') const {rerender} = renderHook(() => useViewTransitions()) // Clear initial calls removeAttributeSpy.mockClear() setAttributeSpy.mockClear() // Change navigation type mockNavigationType.mockReturnValue('POP') rerender() // Should remove old attribute expect(removeAttributeSpy).toHaveBeenCalledWith('data-navigation-type') // And set new one expect(setAttributeSpy).toHaveBeenCalledWith( 'data-navigation-type', 'backward' ) }) }) describe('Edge Cases', () => { it('handles multiple rapid location changes', () => { const {rerender} = renderHook(() => useViewTransitions()) // Simulate rapid location changes for (let i = 0; i < 5; i++) { mockLocation.pathname = `/path-${i}` rerender() } // Should still have proper listeners registered const androidListeners = addEventListenerSpy.mock.calls.filter( call => call[0] === 'androidbackpressed' ) const popstateListeners = addEventListenerSpy.mock.calls.filter( call => call[0] === 'popstate' ) expect(androidListeners.length).toBeGreaterThan(0) expect(popstateListeners.length).toBeGreaterThan(0) }) it('handles missing startViewTransition gracefully', () => { ;(document as any).startViewTransition = undefined // Should not throw expect(() => { renderHook(() => useViewTransitions()) }).not.toThrow() }) it('cleans up properly on unmount during transition', async () => { let resolveTransition: (() => void) | undefined const transitionPromise = new Promise(resolve => { resolveTransition = resolve }) document.startViewTransition = vi.fn((callback: () => void) => { callback() return { finished: transitionPromise, ready: Promise.resolve(), types: new Set(), updateCallbackDone: Promise.resolve(), skipTransition: () => {}, } }) as any const {unmount} = renderHook(() => useViewTransitions()) const androidBackHandler = addEventListenerSpy.mock.calls.find( call => call[0] === 'androidbackpressed' )?.[1] as EventListener // Start transition act(() => { androidBackHandler(new Event('androidbackpressed')) }) // Unmount before transition completes unmount() // Complete transition after unmount if (resolveTransition) { resolveTransition() } // Should have removed listeners expect(removeEventListenerSpy).toHaveBeenCalledWith( 'androidbackpressed', expect.any(Function) ) }) }) })