diff --git a/examples/vite-react-18-suspense-prerender-siblings-problem/tsconfig.tsbuildinfo b/examples/vite-react-18-suspense-prerender-siblings-problem/tsconfig.tsbuildinfo index 832ae3958..6270cb2ff 100644 --- a/examples/vite-react-18-suspense-prerender-siblings-problem/tsconfig.tsbuildinfo +++ b/examples/vite-react-18-suspense-prerender-siblings-problem/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.9.2"} \ No newline at end of file diff --git a/packages/react-dom/src/test-utils/index.spec.ts b/packages/react-dom/src/test-utils/index.spec.ts new file mode 100644 index 000000000..994b3d6ba --- /dev/null +++ b/packages/react-dom/src/test-utils/index.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { intersectionMockInstance, mockAllIsIntersecting, mockIsIntersecting } from './index' + +// Mock console.error to avoid pollution during tests +const originalConsoleError = console.error + +describe('test-utils', () => { + beforeEach(() => { + console.error = vi.fn() + }) + + afterEach(() => { + console.error = originalConsoleError + }) + + describe('intersectionMockInstance error cases', () => { + it('should throw error when no observer found for element', () => { + const element = document.createElement('div') + + expect(() => { + intersectionMockInstance(element) + }).toThrow('Failed to find IntersectionObserver for element. Is it being observed?') + }) + }) + + describe('mockIsIntersecting error cases', () => { + it('should throw error when no observer found for element', () => { + const element = document.createElement('div') + + expect(() => { + mockIsIntersecting(element, true) + }).toThrow('Failed to find IntersectionObserver for element. Is it being observed?') + }) + }) + + describe('mockAllIsIntersecting', () => { + it('should handle empty observers list', () => { + // This should not throw, just handle empty list gracefully + expect(() => { + mockAllIsIntersecting(true) + }).not.toThrow() + }) + }) +}) diff --git a/packages/react/src/lazy.spec.tsx b/packages/react/src/lazy.spec.tsx index 30bdd8ed6..a2d0acea5 100644 --- a/packages/react/src/lazy.spec.tsx +++ b/packages/react/src/lazy.spec.tsx @@ -173,6 +173,21 @@ describe('lazy', () => { expect(screen.queryByText('not loaded')).not.toBeInTheDocument() }) + it('should allow manually preloading a component via load()', async () => { + const mockImport = importCache.createImport({ failureCount: 0, failureDelay: 0, successDelay: 100 }) + const Component = lazy(() => mockImport('/preload-component')) + + const preloadPromise = Component.load() + + expect(importCache.isCached('/preload-component')).toBe(false) + + await act(() => vi.advanceTimersByTimeAsync(100)) + await expect(preloadPromise).resolves.toBeUndefined() + + expect(mockImport).toHaveBeenCalledTimes(1) + expect(importCache.isCached('/preload-component')).toBe(true) + }) + describe('with unified test helper', () => { it('should cache successful imports with timing difference', async () => { const mockImport = importCache.createImport({ failureCount: 0, failureDelay: 100, successDelay: 100 }) @@ -645,4 +660,175 @@ describe('lazy', () => { }) }) }) + + describe('reloadOnError error cases', () => { + const originalWindow = global.window + const mockReload = vi.fn((id: ReturnType) => { + clearTimeout(id) + }) + + afterEach(() => { + global.window = originalWindow + }) + + it('should throw error when no storage is provided and no sessionStorage in window', () => { + // @ts-expect-error - temporarily removing window for testing + delete global.window + + expect(() => reloadOnError({})).toThrow('[@suspensive/react] No storage provided and no sessionStorage in window') + }) + + it('should throw error when no reload function is provided and no location in window', () => { + // @ts-expect-error - temporarily removing window for testing + delete global.window + + expect(() => reloadOnError({ storage })).toThrow( + '[@suspensive/react] No reload function provided and no location in window' + ) + }) + + it('should reach retry limit and stop', async () => { + const lazy = createLazy(reloadOnError({ storage, reload: mockReload, retry: 2 })) + const mockImport = importCache.createImport({ failureCount: 10, failureDelay: 50, successDelay: 50 }) + + // First failure + const Component1 = lazy(() => mockImport('/test-component')) + const { unmount: unmount1 } = render( + error1}> + + + ) + await act(() => vi.advanceTimersByTimeAsync(50)) + expect(screen.getByText('error1')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(mockReload).toHaveBeenCalledTimes(1) + unmount1() + + // Second failure + const Component2 = lazy(() => mockImport('/test-component')) + const { unmount: unmount2 } = render( + error2}> + + + ) + await act(() => vi.advanceTimersByTimeAsync(50)) + expect(screen.getByText('error2')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(mockReload).toHaveBeenCalledTimes(2) + unmount2() + + // Third failure - should not reload anymore + const Component3 = lazy(() => mockImport('/test-component')) + render( + error3}> + + + ) + await act(() => vi.advanceTimersByTimeAsync(50)) + expect(screen.getByText('error3')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(1)) + // Should still be 2, not 3 + expect(mockReload).toHaveBeenCalledTimes(2) + }) + + it('should use window.sessionStorage when no storage provided', () => { + // Mock window.sessionStorage + const mockSessionStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + key: vi.fn(), + length: 0, + clear: vi.fn(), + } + global.window = { sessionStorage: mockSessionStorage } as any + + const options = reloadOnError({ reload: mockReload }) + + // Should not throw error and should have used sessionStorage + expect(options).toBeDefined() + }) + + it('should use window.location.reload when no reload function provided', () => { + const mockLocationReload = vi.fn() + global.window = { + sessionStorage: storage, + location: { reload: mockLocationReload } as any, + } as any + + const options = reloadOnError({ storage }) + + // Should not throw error and should have used location.reload + expect(options).toBeDefined() + }) + + it('should invoke window.location.reload when retrying without custom reload', async () => { + const mockLocationReload = vi.fn() + const mockImport = importCache.createImport({ failureCount: 1, failureDelay: 50, successDelay: 50 }) + global.window = { + sessionStorage: storage, + location: { reload: mockLocationReload } as any, + } as any + + const lazy = createLazy(reloadOnError({ storage })) + const Component = lazy(() => mockImport('/fallback-reload')) + + render( + error}> + + + ) + + await act(() => vi.advanceTimersByTimeAsync(50)) + expect(screen.getByText('error')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(mockLocationReload).toHaveBeenCalledTimes(1) + }) + + it('should use function-based retry delay', async () => { + const delayFn = vi.fn((retryCount: number) => retryCount * 100) + const lazy = createLazy(reloadOnError({ storage, reload: mockReload, retryDelay: delayFn })) + const mockImport = importCache.createImport({ failureCount: 1, failureDelay: 50, successDelay: 50 }) + + const Component = lazy(() => mockImport('/test-component')) + render( + error}> + + + ) + + await act(() => vi.advanceTimersByTimeAsync(50)) + expect(screen.getByText('error')).toBeInTheDocument() + + // The delay function should have been called with retry count 0 + expect(delayFn).toHaveBeenCalledWith(0) + + await act(() => vi.advanceTimersByTimeAsync(1)) // trigger setTimeout with delay=0 + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it('should handle NaN in stored retry count', () => { + // Pre-populate storage with invalid number + const storageKey = 'test-key' + storage.setItem(storageKey, 'invalid-number') + + // Create a custom onError that uses the same storage key format as the actual implementation + const options = reloadOnError({ storage, reload: mockReload }) + const testOnError = options.onError + + // Ensure onError exists + expect(testOnError).toBeDefined() + if (!testOnError) return + + // Call onError directly to test the parseInt logic + testOnError({ + error: new Error('test'), + load: { toString: () => storageKey } as any, + }) + + // The invalid stored value should be removed + expect(storage.getItem(storageKey)).toBe(null) + }) + }) }) diff --git a/packages/react/src/test-utils/index.spec.tsx b/packages/react/src/test-utils/index.spec.tsx new file mode 100644 index 000000000..cae5364b7 --- /dev/null +++ b/packages/react/src/test-utils/index.spec.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Component from './index' + +describe('test-utils default component', () => { + it('should render the default component', () => { + render() + + expect(screen.getByText('Component')).toBeInTheDocument() + }) +})