/** * Race Condition Tests for KTDataTable * Tests the fixes for concurrent request handling, request cancellation, and stale response detection */ import { describe, it, expect, beforeEach, afterEach, vi, type MockedFunction, } from 'vitest'; import { KTDataTable } from '../datatable'; import { waitFor } from './setup'; describe('KTDataTable Race Condition Fixes', () => { let container: HTMLElement; let mockFetch: MockedFunction; let abortSignals: AbortSignal[] = []; beforeEach(() => { // Setup DOM container = document.createElement('div'); container.innerHTML = `
ID Name
`; document.body.appendChild(container); // Mock fetch to track requests and signals abortSignals = []; mockFetch = vi.fn((_url, options) => { // Store abort signal for verification if (options?.signal) { abortSignals.push(options.signal); } // Simulate network delay return new Promise((resolve, reject) => { const timeout = setTimeout(() => { if (options?.signal?.aborted) { reject( new DOMException('The operation was aborted.', 'AbortError'), ); } else { resolve( new Response( JSON.stringify({ data: [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }, ], totalCount: 2, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); } }, 100); // 100ms delay // Handle abort if (options?.signal) { options.signal.addEventListener('abort', () => { clearTimeout(timeout); reject( new DOMException('The operation was aborted.', 'AbortError'), ); }); } }); }); global.fetch = mockFetch as typeof fetch; }); afterEach(() => { document.body.removeChild(container); vi.clearAllMocks(); abortSignals = []; }); describe('AbortController Integration', () => { it('should create AbortController for remote data requests', async () => { new KTDataTable(container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }); await waitFor(150); expect(mockFetch).toHaveBeenCalledTimes(1); expect(abortSignals.length).toBe(1); expect(abortSignals[0]).toBeInstanceOf(AbortSignal); }); it('should use _isFetching flag to prevent concurrent requests', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); // Try to trigger search during initial fetch datatable.search('test'); // Should be blocked by _isFetching await waitFor(150); // Should only have 1 request (initial) because _isFetching blocked the second expect(mockFetch).toHaveBeenCalledTimes(1); }); it('should allow new request after previous completes', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); // Wait for initial fetch to complete await waitFor(150); // Now trigger a search - should succeed datatable.search('test'); await waitFor(150); // Should have 2 requests total expect(mockFetch).toHaveBeenCalledTimes(2); expect(abortSignals.length).toBe(2); }); it('should abort previous request when _performFetchRequest is called again', async () => { // This tests the AbortController logic directly by making multiple sequential requests const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(150); // Complete initial request // Trigger first search datatable.search('first'); await waitFor(150); // Trigger second search (first should complete, second starts fresh) datatable.search('second'); await waitFor(150); // Should have 3 requests: initial + first search + second search expect(mockFetch).toHaveBeenCalledTimes(3); expect(abortSignals.length).toBe(3); // Each gets its own AbortController abortSignals.forEach((signal) => { expect(signal).toBeInstanceOf(AbortSignal); }); }); }); describe('Request ID Sequencing', () => { it('should assign incremental request IDs for sequential requests', async () => { const requestIds: number[] = []; let callCount = 0; // Mock to capture request sequence mockFetch.mockImplementation( (_url: RequestInfo | URL, _options?: RequestInit) => { callCount++; const id = callCount; requestIds.push(id); return new Promise((resolve) => { setTimeout(() => { resolve( new Response( JSON.stringify({ data: [{ id: id, name: `Item ${id}` }], totalCount: 1, }), { status: 200 }, ), ); }, 50); }); }, ); const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(100); // Complete initial request datatable.search('a'); await waitFor(100); // Complete search datatable.search('b'); await waitFor(100); // Complete second search // Request IDs should be sequential: 1, 2, 3 expect(requestIds).toEqual([1, 2, 3]); }); it('should have request ID validation logic in place', async () => { // This tests that request IDs are tracked internally // The actual stale response scenario is prevented by _isFetching flag const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(150); // Complete initial datatable.search('first'); await waitFor(150); // Complete first search datatable.search('second'); await waitFor(150); // Complete second search // All requests should complete successfully with incremental request IDs expect(mockFetch).toHaveBeenCalledTimes(3); // Table should show data from the last successful request const tbody = container.querySelector('tbody'); expect(tbody?.querySelectorAll('tr').length).toBeGreaterThan(0); }); }); describe('_isFetching Flag Management', () => { it('should prevent concurrent fetch executions', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); // Try to trigger reload immediately (should be blocked by initial fetch) datatable.reload(); // Blocked by _isFetching datatable.reload(); // Blocked by _isFetching await waitFor(150); // Should only have 1 request: initial // The reload calls are blocked by _isFetching expect(mockFetch).toHaveBeenCalledTimes(1); }); it('should reset _isFetching flag after fetch completes', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(150); // Wait for initial fetch // Should be able to trigger new fetch after previous completes datatable.reload(); await waitFor(150); expect(mockFetch).toHaveBeenCalledTimes(2); }); it('should reset _isFetching flag even after fetch error', async () => { let callCount = 0; mockFetch.mockImplementation( (_url: RequestInfo | URL, _options?: RequestInit) => { callCount++; if (callCount === 1) { // Return invalid JSON to trigger parse error return Promise.resolve(new Response('Not JSON', { status: 200 })); } return Promise.resolve( new Response( JSON.stringify({ data: [{ id: 1, name: 'Success' }], totalCount: 1, }), { status: 200 }, ), ); }, ); const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(150); // Initial request triggers parse error // Should be able to retry after error datatable.reload(); await waitFor(150); expect(mockFetch).toHaveBeenCalledTimes(2); }); }); describe('Loading Spinner Management', () => { it('should show spinner during fetch', async () => { const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; new KTDataTable(element, { apiEndpoint: '/api/data', }); await waitFor(10); // Spinner should be visible expect(element.classList.contains('loading')).toBe(true); await waitFor(150); // Wait for fetch to complete expect(element.classList.contains('loading')).toBe(false); }); it('should keep spinner visible during overlapping requests', async () => { const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; const datatable = new KTDataTable(element, { apiEndpoint: '/api/data', }); await waitFor(10); expect(element.classList.contains('loading')).toBe(true); // Trigger second request while first is in progress datatable.search('test'); await waitFor(10); // Spinner should still be visible expect(element.classList.contains('loading')).toBe(true); await waitFor(150); // Spinner should hide only after last request completes expect(element.classList.contains('loading')).toBe(false); }); it('should not flicker spinner during rapid interactions', async () => { const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; const datatable = new KTDataTable(element, { apiEndpoint: '/api/data', }); const spinnerStates: boolean[] = []; const checkInterval = setInterval(() => { spinnerStates.push(element.classList.contains('loading')); }, 20); await waitFor(10); datatable.search('a'); await waitFor(10); datatable.search('ab'); await waitFor(10); datatable.search('abc'); await waitFor(150); clearInterval(checkInterval); // Spinner should go from false -> true -> false // No flickering (true -> false -> true) const transitions = spinnerStates.reduce((acc, curr, idx) => { if (idx > 0 && spinnerStates[idx - 1] !== curr) { acc.push(curr); } return acc; }, [] as boolean[]); // Should have exactly 2 transitions: off->on, on->off // No additional transitions that would indicate flickering expect(transitions.length).toBeLessThanOrEqual(2); }); }); describe('Event Handling During Race Conditions', () => { it('should fire fetch event for successful requests', async () => { const fetchEvents: Event[] = []; const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; element.addEventListener('fetch', (e) => { fetchEvents.push(e); }); const datatable = new KTDataTable(element, { apiEndpoint: '/api/data', }); await waitFor(150); // Complete initial datatable.search('test'); await waitFor(150); // Complete search // Should fire fetch for initial and search expect(fetchEvents.length).toBeGreaterThanOrEqual(2); }); it('should fire fetched event after successful data load', async () => { const fetchedEvents: Event[] = []; const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; element.addEventListener('fetched', (e) => { fetchedEvents.push(e); }); new KTDataTable(element, { apiEndpoint: '/api/data', }); await waitFor(150); // Should fire fetched for initial request expect(fetchedEvents.length).toBeGreaterThanOrEqual(1); }); it('should not fire error events for AbortError', async () => { const errorEvents: Event[] = []; const element = container.querySelector( '[data-kt-datatable="true"]', ) as HTMLElement; element.addEventListener('error.kt.datatable', (e) => { errorEvents.push(e); }); const datatable = new KTDataTable(element, { apiEndpoint: '/api/data', }); await waitFor(10); datatable.search('test'); // Cancels previous await waitFor(150); // AbortError should not trigger error event expect(errorEvents.length).toBe(0); }); }); describe('Backward Compatibility', () => { it('should work with local data mode (no AbortController needed)', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, ); // Should not call fetch for local data expect(mockFetch).not.toHaveBeenCalled(); // Should still work without errors datatable.search('Item 1'); datatable.sort('name'); datatable.goPage(1); }); it('should maintain existing API compatibility', async () => { const datatable = new KTDataTable( container.querySelector('[data-kt-datatable="true"]')!, { apiEndpoint: '/api/data', }, ); await waitFor(150); // All existing methods should work expect(() => { datatable.reload(); datatable.search('test'); datatable.sort('name'); datatable.goPage(1); datatable.setPageSize(20); datatable.getState(); }).not.toThrow(); }); }); });