@shopify/shop-minis-react
Version:
React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)
425 lines (338 loc) • 12.1 kB
text/typescript
import {renderHook, act} from '@testing-library/react'
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import {useNavigateWithTransition} from './useNavigateWithTransition'
// Mock react-router
const mockNavigate = vi.fn()
const mockLocation = {pathname: '/current'}
vi.mock('react-router', () => ({
useNavigate: () => mockNavigate,
useLocation: () => mockLocation,
NavigateOptions: {},
}))
describe('useNavigateWithTransition', () => {
let originalStartViewTransition: any
beforeEach(() => {
vi.clearAllMocks()
originalStartViewTransition = document.startViewTransition
mockLocation.pathname = '/current'
})
afterEach(() => {
document.startViewTransition = originalStartViewTransition
document.documentElement.removeAttribute('data-navigation-type')
})
describe('Without View Transitions API', () => {
beforeEach(() => {
// Remove startViewTransition to simulate unsupported browser
;(document as any).startViewTransition = undefined
})
it('navigates directly when view transitions not supported', () => {
const {result} = renderHook(() => useNavigateWithTransition())
act(() => {
result.current('/new-route')
})
expect(mockNavigate).toHaveBeenCalledWith('/new-route', undefined)
})
it('navigates with options when view transitions not supported', () => {
const {result} = renderHook(() => useNavigateWithTransition())
const options = {replace: true, state: {from: 'test'}}
act(() => {
result.current('/new-route', options)
})
expect(mockNavigate).toHaveBeenCalledWith('/new-route', options)
})
it('handles delta navigation without view transitions', () => {
const {result} = renderHook(() => useNavigateWithTransition())
act(() => {
result.current(-1)
})
expect(mockNavigate).toHaveBeenCalledWith(-1)
})
})
describe('With View Transitions API', () => {
let mockTransition: any
beforeEach(() => {
mockTransition = {
finished: Promise.resolve(),
ready: Promise.resolve(),
types: new Set<string>(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
}
// Mock startViewTransition
document.startViewTransition = vi.fn((callback: () => void) => {
callback()
return mockTransition
}) as any
})
it('uses view transition for path navigation', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/new-route')
await mockTransition.finished
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/new-route', {
replace: false,
})
expect(scrollToSpy).toHaveBeenCalledWith(0, 0)
scrollToSpy.mockRestore()
})
it('uses replace navigation for same route', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/current')
await mockTransition.finished
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/current', {
replace: true,
})
expect(scrollToSpy).toHaveBeenCalledWith(0, 0)
scrollToSpy.mockRestore()
})
it('merges custom options with defaults', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
const customOptions = {state: {from: 'test'}, replace: true}
await act(async () => {
result.current('/new-route', customOptions)
await mockTransition.finished
})
expect(mockNavigate).toHaveBeenCalledWith('/new-route', {
replace: true,
state: {from: 'test'},
})
expect(scrollToSpy).toHaveBeenCalledWith(0, 0)
scrollToSpy.mockRestore()
})
it('removes navigation type attribute after transition completes', async () => {
const removeAttributeSpy = vi.spyOn(
document.documentElement,
'removeAttribute'
)
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/new-route')
await mockTransition.finished
})
expect(removeAttributeSpy).toHaveBeenCalledWith('data-navigation-type')
})
it('handles view transition errors gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const errorTransition = {
finished: Promise.reject(new Error('Transition failed')),
ready: Promise.resolve(),
types: new Set<string>(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
}
document.startViewTransition = vi.fn((callback: () => void) => {
callback()
return errorTransition
}) as any
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/new-route')
try {
await errorTransition.finished
} catch {
// Expected error
}
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'View transition error:',
expect.any(Error)
)
consoleErrorSpy.mockRestore()
})
it('uses view transition for delta navigation', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current(-1)
await mockTransition.finished
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith(-1)
// Delta navigation should NOT reset scroll
expect(scrollToSpy).not.toHaveBeenCalled()
scrollToSpy.mockRestore()
})
it('removes attribute after delta navigation transition', async () => {
const removeAttributeSpy = vi.spyOn(
document.documentElement,
'removeAttribute'
)
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current(-2)
await mockTransition.finished
})
expect(removeAttributeSpy).toHaveBeenCalledWith('data-navigation-type')
})
it('handles positive delta navigation', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current(1)
await mockTransition.finished
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith(1)
// Delta navigation should NOT reset scroll
expect(scrollToSpy).not.toHaveBeenCalled()
scrollToSpy.mockRestore()
})
it('does not reset scroll when preventScrollReset is true', async () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo')
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/new-route', {preventScrollReset: true})
await mockTransition.finished
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/new-route', {
replace: false,
preventScrollReset: true,
})
expect(scrollToSpy).not.toHaveBeenCalled()
scrollToSpy.mockRestore()
})
})
describe('Edge Cases', () => {
beforeEach(() => {
document.startViewTransition = vi.fn((callback: () => void) => {
callback()
return {
finished: Promise.resolve(),
ready: Promise.resolve(),
types: new Set<string>(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
}
}) as any
})
it('handles navigation to empty string', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('')
})
expect(mockNavigate).toHaveBeenCalledWith('', {
replace: false,
})
})
it('handles navigation to root path using history delta', async () => {
// Mock history state with idx
const originalHistoryState = window.history.state
Object.defineProperty(window.history, 'state', {
value: {idx: 3},
writable: true,
configurable: true,
})
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/')
})
// Should navigate back using delta based on history index
expect(mockNavigate).toHaveBeenCalledWith(-3)
// Restore original state
Object.defineProperty(window.history, 'state', {
value: originalHistoryState,
writable: true,
configurable: true,
})
})
it('handles navigation with query parameters', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/search?q=test&page=2')
})
expect(mockNavigate).toHaveBeenCalledWith('/search?q=test&page=2', {
replace: false,
})
})
it('handles navigation with hash', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/page#section')
})
expect(mockNavigate).toHaveBeenCalledWith('/page#section', {
replace: false,
})
})
it('handles zero delta navigation', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current(0)
})
expect(document.startViewTransition).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith(0)
})
})
describe('Multiple Navigations', () => {
beforeEach(() => {
const transitions: any[] = []
document.startViewTransition = vi.fn((callback: () => void) => {
callback()
const transition = {
finished: new Promise(resolve => setTimeout(resolve, 10)),
ready: Promise.resolve(),
types: new Set<string>(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
}
transitions.push(transition)
return transition
}) as any
})
it('handles multiple sequential navigations', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/route1')
result.current('/route2')
result.current('/route3')
})
expect(document.startViewTransition).toHaveBeenCalledTimes(3)
expect(mockNavigate).toHaveBeenCalledTimes(3)
expect(mockNavigate).toHaveBeenNthCalledWith(
1,
'/route1',
expect.any(Object)
)
expect(mockNavigate).toHaveBeenNthCalledWith(
2,
'/route2',
expect.any(Object)
)
expect(mockNavigate).toHaveBeenNthCalledWith(
3,
'/route3',
expect.any(Object)
)
})
it('handles mixed path and delta navigations', async () => {
const {result} = renderHook(() => useNavigateWithTransition())
await act(async () => {
result.current('/forward')
result.current(-1)
result.current('/another')
})
expect(document.startViewTransition).toHaveBeenCalledTimes(3)
expect(mockNavigate).toHaveBeenNthCalledWith(
1,
'/forward',
expect.any(Object)
)
expect(mockNavigate).toHaveBeenNthCalledWith(2, -1)
expect(mockNavigate).toHaveBeenNthCalledWith(
3,
'/another',
expect.any(Object)
)
})
})
})