diff --git a/src/data-loaders/defineColadaLoader.spec.ts b/src/data-loaders/defineColadaLoader.spec.ts index 691316ed9..b77ce409e 100644 --- a/src/data-loaders/defineColadaLoader.spec.ts +++ b/src/data-loaders/defineColadaLoader.spec.ts @@ -383,5 +383,419 @@ describe( // FIXME: // expect(nestedQuery).toHaveBeenCalledTimes(2) }) + + it('handles query function errors gracefully', async () => { + const error = new Error('Query failed') + const query = vi.fn().mockRejectedValue(error) + const useData = defineColadaLoader({ + query, + key: () => ['error-test'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { error: loaderError, isLoading } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(loaderError.value).toBe(error) + expect(isLoading.value).toBe(false) + }) + + it('handles dynamic key changes correctly', async () => { + const query = vi + .fn() + .mockImplementation(async (to) => `data-${to.params.id}`) + const useData = defineColadaLoader({ + query, + key: (to) => ['dynamic', to.params.id as string], + }) + + let useDataResult: ReturnType | undefined + const component = defineComponent({ + setup() { + useDataResult = useData() + const { data, error, isLoading } = useDataResult + return { data, error, isLoading } + }, + template: `

`, + }) + + const router = getRouter() + router.addRoute({ + name: 'dynamic-test', + path: '/items/:id', + meta: { loaders: [useData] }, + component, + }) + + mount(RouterViewMock, { + global: { + plugins: [[DataLoaderPlugin, { router }], createPinia(), PiniaColada], + }, + }) + + await router.push('/items/1') + expect(query).toHaveBeenCalledTimes(1) + expect(useDataResult!.data.value).toBe('data-1') + + await router.push('/items/2') + expect(query).toHaveBeenCalledTimes(2) + expect(useDataResult!.data.value).toBe('data-2') + }) + + it('handles concurrent navigation correctly', async () => { + const query = vi.fn().mockImplementation(async (to) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return `data-${to.query.id}` + }) + const useData = defineColadaLoader({ + query, + key: (to) => ['concurrent', to.query.id as string], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + // Start multiple concurrent navigations + const navigation1 = router.push('/fetch?id=1') + const navigation2 = router.push('/fetch?id=2') + const navigation3 = router.push('/fetch?id=3') + + await Promise.all([navigation1, navigation2, navigation3]) + await vi.runAllTimersAsync() + + const { data } = useDataFn() + // Should have the data from the last navigation + expect(data.value).toBe('data-3') + // Query should be called for each unique key + expect(query).toHaveBeenCalledTimes(3) + }) + + it('properly manages loading state transitions', async () => { + let resolveQuery: (value: string) => void + const query = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveQuery = resolve + }) + ) + + const useData = defineColadaLoader({ + query, + key: () => ['loading-test'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + const navigationPromise = router.push('/fetch') + const { isLoading, data } = useDataFn() + + // Should be loading initially + expect(isLoading.value).toBe(true) + expect(data.value).toBeUndefined() + + // Resolve the query + resolveQuery!('loaded-data') + await navigationPromise + await nextTick() + + // Should no longer be loading + expect(isLoading.value).toBe(false) + expect(data.value).toBe('loaded-data') + }) + + it('handles error recovery scenarios', async () => { + const error = new Error('Network error') + const query = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce('recovery-data') + + const useData = defineColadaLoader({ + query, + key: () => ['error-recovery'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data, error: loaderError, reload } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(loaderError.value).toBe(error) + expect(data.value).toBeUndefined() + + // Attempt recovery + await reload() + expect(query).toHaveBeenCalledTimes(2) + expect(loaderError.value).toBe(null) + expect(data.value).toBe('recovery-data') + }) + + it('handles different data types correctly', async () => { + const complexData = { + users: [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ], + meta: { total: 2, page: 1 }, + nested: { deep: { value: 'test' } }, + } + + const query = vi.fn().mockResolvedValue(complexData) + const useData = defineColadaLoader({ + query, + key: () => ['complex-data'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toEqual(complexData) + expect(data.value?.users).toHaveLength(2) + expect(data.value?.nested.deep.value).toBe('test') + }) + + it('handles null and undefined data correctly', async () => { + const query = vi.fn().mockResolvedValue(null) + const useData = defineColadaLoader({ + query, + key: () => ['null-data'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toBe(null) + }) + + it('handles route parameter changes in key function', async () => { + const query = vi + .fn() + .mockImplementation(async (to) => `user-${to.params.userId}`) + const useData = defineColadaLoader({ + query, + key: (to) => [ + 'user', + to.params.userId as string, + to.query.version as string, + ], + }) + + let useDataResult: ReturnType | undefined + const component = defineComponent({ + setup() { + useDataResult = useData() + return { ...useDataResult } + }, + template: `

`, + }) + + const router = getRouter() + router.addRoute({ + name: 'user-profile', + path: '/users/:userId', + meta: { loaders: [useData] }, + component, + }) + + mount(RouterViewMock, { + global: { + plugins: [[DataLoaderPlugin, { router }], createPinia(), PiniaColada], + }, + }) + + await router.push('/users/123?version=v1') + expect(query).toHaveBeenCalledTimes(1) + expect(useDataResult!.data.value).toBe('user-123') + + // Change version - should fetch again due to key change + await router.push('/users/123?version=v2') + expect(query).toHaveBeenCalledTimes(2) + expect(useDataResult!.data.value).toBe('user-123') + + // Change user ID - should fetch again + await router.push('/users/456?version=v2') + expect(query).toHaveBeenCalledTimes(3) + expect(useDataResult!.data.value).toBe('user-456') + }) + + it('handles cache invalidation correctly', async () => { + const query = vi + .fn() + .mockResolvedValueOnce('cached-data') + .mockResolvedValueOnce('fresh-data') + + const useData = defineColadaLoader({ + query, + key: () => ['cache-invalidation'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toBe('cached-data') + + // Create a new mount with same cache to test cache persistence + const wrapper = mount( + defineComponent({ + setup() { + const caches = useQueryCache() + return { caches } + }, + template: `

`, + }), + { + global: { + plugins: [getActivePinia()!, PiniaColada], + }, + } + ) + + // Invalidate cache + await wrapper.vm.caches.invalidateQueries({ key: ['cache-invalidation'] }) + + const { data: freshData, reload } = useDataFn() + await reload() + + expect(query).toHaveBeenCalledTimes(2) + expect(freshData.value).toBe('fresh-data') + }) + + it('handles multiple loaders with same key correctly', async () => { + const sharedQuery = vi.fn().mockResolvedValue('shared-data') + + const useData1 = defineColadaLoader({ + query: sharedQuery, + key: () => ['shared'], + }) + + const useData2 = defineColadaLoader({ + query: sharedQuery, + key: () => ['shared'], + }) + + const { router: router1, useData: useDataFn1 } = + singleLoaderOneRoute(useData1) + const { router: router2, useData: useDataFn2 } = + singleLoaderOneRoute(useData2) + + await router1.push('/fetch') + await router2.push('/fetch') + + const { data: data1 } = useDataFn1() + const { data: data2 } = useDataFn2() + + // Should only call query once due to shared cache + expect(sharedQuery).toHaveBeenCalledTimes(1) + expect(data1.value).toBe('shared-data') + expect(data2.value).toBe('shared-data') + }) + + it('handles empty key arrays', async () => { + const query = vi.fn().mockResolvedValue('empty-key-data') + const useData = defineColadaLoader({ + query, + key: () => [], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toBe('empty-key-data') + }) + + it('handles special characters in keys', async () => { + const specialKey = [ + 'special', + 'key-with/slashes', + 'key with spaces', + 'key@with#symbols', + ] + const query = vi.fn().mockResolvedValue('special-key-data') + const useData = defineColadaLoader({ + query, + key: () => specialKey, + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toBe('special-key-data') + }) + + it('supports manual reload functionality', async () => { + const query = vi + .fn() + .mockResolvedValueOnce('initial-data') + .mockResolvedValueOnce('reloaded-data') + + const useData = defineColadaLoader({ + query, + key: () => ['reload-test'], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + await router.push('/fetch') + const { data, reload } = useDataFn() + + expect(query).toHaveBeenCalledTimes(1) + expect(data.value).toBe('initial-data') + + await reload() + expect(query).toHaveBeenCalledTimes(2) + expect(data.value).toBe('reloaded-data') + }) + + it('handles stale data scenarios correctly', async () => { + let resolveQuery: (value: string) => void + let queryCount = 0 + const query = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + queryCount++ + resolveQuery = (value) => resolve(`${value}-${queryCount}`) + }) + ) + const useData = defineColadaLoader({ + query, + key: (to) => ['stale', to.query.v as string], + }) + + const { router, useData: useDataFn } = singleLoaderOneRoute(useData) + + // Start first navigation + const firstNavigation = router.push('/fetch?v=1') + expect(query).toHaveBeenCalledTimes(1) + + // Start second navigation before first completes + const secondNavigation = router.push('/fetch?v=2') + expect(query).toHaveBeenCalledTimes(2) + + // Complete second query first + resolveQuery!('data') + await secondNavigation + await vi.runAllTimersAsync() + + const { data } = useDataFn() + // Should have data from the second query + expect(data.value).toBe('data-2') + }) } ) diff --git a/src/data-loaders/defineLoader.spec.ts b/src/data-loaders/defineLoader.spec.ts index b91c3b2db..f5d7165b1 100644 --- a/src/data-loaders/defineLoader.spec.ts +++ b/src/data-loaders/defineLoader.spec.ts @@ -272,3 +272,750 @@ describe( }) } ) + +describe('defineBasicLoader - additional edge cases', () => { + it('should handle loader function throwing synchronous errors', async () => { + const error = new Error('Synchronous error') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader( + () => { + throw error + }, + { key: 'sync-error' } + ) + ) + + await router.push('/fetch').catch(() => {}) + const { data, error: loaderError, isLoading } = useData() + + expect(data.value).toBeUndefined() + expect(loaderError.value).toBe(error) + expect(isLoading.value).toBe(false) + }) + + it('should handle empty string return values from loader', async () => { + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => '', { key: 'empty-string' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle boolean return values from loader', async () => { + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => false, { key: 'boolean-false' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe(false) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle zero return values from loader', async () => { + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => 0, { key: 'zero-value' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe(0) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle complex object return values', async () => { + const complexData = { + users: [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ], + metadata: { total: 2, page: 1 }, + nested: { deep: { value: 'test' } }, + } + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => complexData, { key: 'complex-data' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toEqual(complexData) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with route parameter dependencies', async () => { + const spy = vi + .fn() + .mockImplementation(async (route) => `user-${route.params.id}`) + const router = getRouter() + + // Create a parameterized route + router.addRoute({ + name: '_test_params', + path: '/fetch/:id', + component: defineComponent({ + setup() { + const loader = defineBasicLoader(spy, { key: 'route-params' }) + const result = loader() + return result + }, + template: '
{{ data }}
', + }), + meta: { loaders: [defineBasicLoader(spy, { key: 'route-params' })] }, + }) + + mount(RouterViewMock, { + global: { + plugins: [[DataLoaderPlugin, { router }]], + }, + }) + + await router.push('/fetch/123') + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: '123' }, + }) + ) + }) + + it('should handle loader with query parameter dependencies', async () => { + const spy = vi + .fn() + .mockImplementation(async (route) => `search-${route.query.q}`) + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'query-params' }) + ) + + await router.push('/fetch?q=test-query') + const { data } = useData() + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + query: { q: 'test-query' }, + }) + ) + expect(data.value).toBe('search-test-query') + }) + + it('should handle very large data payloads', async () => { + const largeArray = Array.from({ length: 1000 }, (_, i) => ({ + id: i, + data: `item-${i}`, + nested: { value: i * 2 }, + })) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => largeArray, { key: 'large-data' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toHaveLength(1000) + expect(data.value[0]).toEqual({ + id: 0, + data: 'item-0', + nested: { value: 0 }, + }) + expect(data.value[999]).toEqual({ + id: 999, + data: 'item-999', + nested: { value: 1998 }, + }) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle circular reference in data', async () => { + const circularData: any = { name: 'test' } + circularData.self = circularData + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => circularData, { key: 'circular' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value.name).toBe('test') + expect(data.value.self).toBe(data.value) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle Date objects in loader data', async () => { + const testDate = new Date('2023-01-01T00:00:00.000Z') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => ({ timestamp: testDate }), { + key: 'date-data', + }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value.timestamp).toBe(testDate) + expect(data.value.timestamp instanceof Date).toBe(true) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle Map and Set objects in loader data', async () => { + const testMap = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]) + const testSet = new Set([1, 2, 3, 4, 5]) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => ({ map: testMap, set: testSet }), { + key: 'collections', + }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value.map).toBe(testMap) + expect(data.value.set).toBe(testSet) + expect(data.value.map.get('key1')).toBe('value1') + expect(data.value.set.has(3)).toBe(true) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle NavigationResult return values', async () => { + const navigationResult = new NavigationResult('/') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => navigationResult, { + key: 'navigation-result', + }) + ) + + await router.push('/fetch') + const { data, isLoading } = useData() + + // NavigationResult should not be set as data but handled by the navigation system + expect(data.value).toBeUndefined() + expect(isLoading.value).toBe(false) + }) +}) + +describe('defineBasicLoader - options edge cases', () => { + it('should handle loader with empty key string', async () => { + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => 'test-data', { key: '' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('test-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with special characters in key', async () => { + const specialKey = 'key-with-!@#$%^&*()_+{}[]|\\:";\'<>?,./' + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => 'special-key-data', { key: specialKey }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('special-key-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with very long key', async () => { + const longKey = 'a'.repeat(1000) + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => 'long-key-data', { key: longKey }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('long-key-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with unicode characters in key', async () => { + const unicodeKey = '测试-🎉-🚀-key-العربية-русский' + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => 'unicode-data', { key: unicodeKey }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('unicode-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with different commit strategies', async () => { + const spy = vi.fn().mockResolvedValue('immediate-commit-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'immediate', commit: 'immediate' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('immediate-commit-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle loader with server option disabled', async () => { + const spy = vi.fn().mockResolvedValue('client-only-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'client-only', server: false }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toBe('client-only-data') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) +}) + +describe('defineBasicLoader - server-side rendering scenarios', () => { + it('should handle server initial data with missing keys', async () => { + const spy = vi.fn().mockResolvedValue('fallback-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'missing-ssr-key' }) + ) + + router[INITIAL_DATA_KEY] = { 'different-key': 'different-data' } + + await router.push('/fetch') + const { data } = useData() + + expect(spy).toHaveBeenCalledTimes(1) + expect(data.value).toBe('fallback-data') + }) + + it('should handle corrupted server initial data', async () => { + const spy = vi.fn().mockResolvedValue('fallback-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'corrupted-key' }) + ) + + // Simulate corrupted data - set to null + router[INITIAL_DATA_KEY] = null + + await router.push('/fetch') + const { data } = useData() + + expect(spy).toHaveBeenCalledTimes(1) + expect(data.value).toBe('fallback-data') + }) + + it('should handle server initial data with null values', async () => { + const spy = vi.fn().mockResolvedValue('fallback-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'null-ssr' }) + ) + + router[INITIAL_DATA_KEY] = { 'null-ssr': null } + + await router.push('/fetch') + const { data } = useData() + + expect(spy).toHaveBeenCalledTimes(0) + expect(data.value).toBeNull() + }) + + it('should prioritize INITIAL_DATA_KEY over SERVER_INITIAL_DATA_KEY', async () => { + const spy = vi.fn().mockResolvedValue('fallback-data') + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'priority-test' }) + ) + + router[SERVER_INITIAL_DATA_KEY] = { 'priority-test': 'server-data' } + router[INITIAL_DATA_KEY] = { 'priority-test': 'initial-data' } + + await router.push('/fetch') + const { data } = useData() + + // Should use INITIAL_DATA_KEY and not call the loader + expect(spy).toHaveBeenCalledTimes(0) + expect(data.value).toBe('initial-data') + }) +}) + +describe('defineBasicLoader - error recovery', () => { + it('should recover from errors on subsequent navigations', async () => { + let shouldError = true + const spy = vi.fn().mockImplementation(async () => { + if (shouldError) { + shouldError = false + throw new Error('First call error') + } + return 'success-data' + }) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'error-recovery', lazy: true }) + ) + + // First navigation should error + await router.push('/fetch?attempt=1') + let { data, error } = useData() + await vi.runAllTimersAsync() + expect(error.value).toBeInstanceOf(Error) + expect(data.value).toBeUndefined() + + // Second navigation should succeed + await router.push('/fetch?attempt=2') + const result = useData() + await vi.runAllTimersAsync() + expect(result.error.value).toBeNull() + expect(result.data.value).toBe('success-data') + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('should handle mixed success and error scenarios', async () => { + const spy = vi.fn().mockImplementation(async (route) => { + const shouldError = route.query.error === 'true' + if (shouldError) { + throw new Error(`Error for ${route.query.id}`) + } + return `Success for ${route.query.id}` + }) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'mixed-results', lazy: true }) + ) + + // Success case + await router.push('/fetch?id=1&error=false') + let result = useData() + await vi.runAllTimersAsync() + expect(result.error.value).toBeNull() + expect(result.data.value).toBe('Success for 1') + + // Error case + await router.push('/fetch?id=2&error=true') + result = useData() + await vi.runAllTimersAsync() + expect(result.error.value).toBeInstanceOf(Error) + expect(result.data.value).toBeUndefined() + + // Success again + await router.push('/fetch?id=3&error=false') + result = useData() + await vi.runAllTimersAsync() + expect(result.error.value).toBeNull() + expect(result.data.value).toBe('Success for 3') + }) +}) + +describe('defineBasicLoader - type safety and validation', () => { + it('should handle loaders returning different types', async () => { + interface UserData { + id: number + name: string + active: boolean + } + + const userData: UserData = { id: 1, name: 'John', active: true } + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async (): Promise => userData, { + key: 'typed-data', + }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toEqual(userData) + expect(typeof data.value?.id).toBe('number') + expect(typeof data.value?.name).toBe('string') + expect(typeof data.value?.active).toBe('boolean') + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) + + it('should handle arrays of different types', async () => { + const mixedArray = [1, 'string', { obj: true }, [1, 2, 3], null, undefined] + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(async () => mixedArray, { key: 'mixed-array' }) + ) + + await router.push('/fetch') + const { data, error, isLoading } = useData() + + expect(data.value).toEqual(mixedArray) + expect(Array.isArray(data.value)).toBe(true) + expect(data.value).toHaveLength(6) + expect(error.value).toBeNull() + expect(isLoading.value).toBe(false) + }) +}) + +describe('defineBasicLoader - reload functionality', () => { + it('should allow manual reloading of data', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce('initial-data') + .mockResolvedValueOnce('reloaded-data') + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'reload-test' }) + ) + + await router.push('/fetch') + const { data, reload } = useData() + + expect(data.value).toBe('initial-data') + expect(spy).toHaveBeenCalledTimes(1) + + // Reload the data + await reload() + + expect(data.value).toBe('reloaded-data') + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('should handle reload with different route', async () => { + const spy = vi + .fn() + .mockImplementation(async (route) => `data-${route.query.version}`) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'reload-route-test' }) + ) + + await router.push('/fetch?version=1') + const { data, reload } = useData() + + expect(data.value).toBe('data-1') + + // Navigate to different route first + await router.push('/fetch?version=2') + expect(data.value).toBe('data-2') + + // Reload with original route + const originalRoute = router.resolve('/fetch?version=1') + .route as RouteLocationNormalizedLoaded + await reload(originalRoute) + expect(data.value).toBe('data-1') + }) + + it('should handle reload errors gracefully', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce('initial-data') + .mockRejectedValueOnce(new Error('Reload error')) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'reload-error-test' }) + ) + + await router.push('/fetch') + const { data, error, reload } = useData() + + expect(data.value).toBe('initial-data') + expect(error.value).toBeNull() + + // Reload should handle error + await reload().catch(() => {}) // Catch to prevent unhandled rejection + + expect(error.value).toBeInstanceOf(Error) + expect(error.value?.message).toBe('Reload error') + }) +}) + +describe('defineBasicLoader - abort controller scenarios', () => { + it('should handle aborted requests gracefully', async () => { + const spy = vi.fn().mockImplementation(async (route, { signal }) => { + // Simulate checking abort signal + if (signal?.aborted) { + throw new Error('Request was aborted') + } + return 'success-data' + }) + + const { router } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'abort-test' }) + ) + + const navigation = router.push('/fetch') + // Immediately navigate away to trigger abort + router.push('/') + + await navigation.catch(() => {}) // Handle potential navigation error + + expect(spy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ) + }) + + it('should pass abort signal to nested loaders', async () => { + const nestedSpy = vi.fn().mockImplementation(async (route, { signal }) => { + expect(signal).toBeInstanceOf(AbortSignal) + return 'nested-data' + }) + + const nestedLoader = defineBasicLoader(nestedSpy, { key: 'nested-abort' }) + + const parentSpy = vi.fn().mockImplementation(async (route, context) => { + const nestedData = await nestedLoader() + return `parent-${nestedData}` + }) + + const { router } = singleLoaderOneRoute( + defineBasicLoader(parentSpy, { key: 'parent-abort' }) + ) + + await router.push('/fetch') + + expect(nestedSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ) + }) +}) + +describe('defineBasicLoader - memory management', () => { + it('should not accumulate memory leaks with repeated navigations', async () => { + const spy = vi + .fn() + .mockImplementation(async (route) => `data-${route.query.iteration}`) + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'memory-test' }) + ) + + // Perform many navigations to test for memory leaks + for (let i = 0; i < 50; i++) { + await router.push(`/fetch?iteration=${i}`) + } + + const { data } = useData() + expect(data.value).toBe('data-49') + expect(spy).toHaveBeenCalledTimes(50) + }) + + it('should clean up pending loads when navigation is cancelled', async () => { + const { spy, resolve } = mockedLoader({ key: 'cleanup-test' }) + const { router, useData } = singleLoaderOneRoute(spy.loader) + + // Start navigation but cancel it immediately + const navigation = router.push('/fetch') + router.push('/') // Cancel by navigating elsewhere + + // Even if we resolve later, it shouldn't affect the cancelled navigation + resolve('should-not-be-used') + await navigation.catch(() => {}) + + const { data, isLoading } = useData() + expect(isLoading.value).toBe(false) + expect(data.value).toBeUndefined() + }) +}) + +describe('defineBasicLoader - edge case scenarios', () => { + it('should handle loader key collisions between different instances', async () => { + const spy1 = vi.fn().mockResolvedValue('loader1-data') + const spy2 = vi.fn().mockResolvedValue('loader2-data') + + const loader1 = defineBasicLoader(spy1, { key: 'shared-key' }) + const loader2 = defineBasicLoader(spy2, { key: 'shared-key' }) + + const router = getRouter() + let result1: any + let result2: any + + router.addRoute({ + name: '_test', + path: '/fetch', + component: defineComponent({ + setup() { + result1 = loader1() + result2 = loader2() + return { data1: result1.data, data2: result2.data } + }, + template: '
{{ data1 }} {{ data2 }}
', + }), + meta: { loaders: [loader1, loader2] }, + }) + + mount(RouterViewMock, { + global: { + plugins: [[DataLoaderPlugin, { router }]], + }, + }) + + await router.push('/fetch') + + // Both should be called since they are different loader functions + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy2).toHaveBeenCalledTimes(1) + expect(result1.data.value).toBe('loader1-data') + expect(result2.data.value).toBe('loader2-data') + }) + + it('should handle extremely long loading times', async () => { + const { spy } = mockedLoader({ key: 'long-loading' }) + const { router, useData } = singleLoaderOneRoute(spy.loader) + + await router.push('/fetch') + + // Advance timers to simulate very long loading + vi.advanceTimersByTime(60000) // 1 minute + + const { isLoading, data } = useData() + // Should still be loading since we never resolve the promise + expect(isLoading.value).toBe(true) + expect(data.value).toBeUndefined() + expect(spy.spy).toHaveBeenCalledTimes(1) + }) + + it('should handle rapid successive same-route navigations', async () => { + const spy = vi.fn().mockImplementation(async (route) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return `data-${route.query.id}` + }) + + const { router, useData } = singleLoaderOneRoute( + defineBasicLoader(spy, { key: 'rapid-same' }) + ) + + // Rapidly trigger the same navigation multiple times + const promises = [] + for (let i = 0; i < 5; i++) { + promises.push(router.push('/fetch?id=same')) + } + + await Promise.all(promises) + + const { data } = useData() + // Should only call loader once for the same route + expect(spy).toHaveBeenCalledTimes(1) + expect(data.value).toBe('data-same') + }) +})