Yuki Takei 5 месяцев назад
Родитель
Сommit
64598c01d1
2 измененных файлов с 505 добавлено и 0 удалено
  1. 423 0
      apps/app/src/client/util/use-lazy-loader.spec.tsx
  2. 82 0
      apps/app/src/client/util/use-lazy-loader.ts

+ 423 - 0
apps/app/src/client/util/use-lazy-loader.spec.tsx

@@ -0,0 +1,423 @@
+import React from 'react';
+
+import { renderHook, waitFor } from '@testing-library/react';
+import {
+  describe, it, expect, vi, beforeEach,
+} from 'vitest';
+
+import { useLazyLoader, clearComponentCache } from './use-lazy-loader';
+
+describe('useLazyLoader', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Clear the global component cache to ensure test isolation
+    clearComponentCache();
+  });
+
+  describe('Basic functionality', () => {
+    it('should load component when isActive is true', async () => {
+      // Arrange
+      const MockComponent = () => <div>Loaded</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('test-key', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not load component when isActive is false', () => {
+      // Arrange
+      const mockImport = vi.fn();
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('test-key', mockImport, false));
+
+      // Assert
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+    });
+
+    it('should return null initially and load component asynchronously', async () => {
+      // Arrange
+      const MockComponent = () => <div>Async Loaded</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('async-key', mockImport, true));
+
+      // Assert - Initially null
+      expect(result.current).toBeNull();
+
+      // Assert - After loading
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+    });
+  });
+
+  describe('Cache functionality', () => {
+    it('should use cache for the same importKey', async () => {
+      // Arrange
+      const MockComponent = () => <div>Cached</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - First call
+      const { result: result1 } = renderHook(() => useLazyLoader('cached-key', mockImport, true));
+
+      await waitFor(() => {
+        expect(result1.current).toBe(MockComponent);
+      });
+
+      // Act - Second call with same key
+      const { result: result2 } = renderHook(() => useLazyLoader('cached-key', mockImport, true));
+
+      await waitFor(() => {
+        expect(result2.current).toBe(MockComponent);
+      });
+
+      // Assert - Import should be called only once
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not use cache for different importKeys', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+
+      // Act
+      const { result: result1 } = renderHook(() => useLazyLoader('key1', mockImport1, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('key2', mockImport2, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+        expect(result2.current).toBe(Component2);
+      });
+
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('State change functionality', () => {
+    it('should load component when isActive changes from false to true', async () => {
+      // Arrange
+      const MockComponent = () => <div>Dynamic</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Initial render with isActive=false
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('dynamic-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Assert - Should not load initially
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+
+      // Act - Change isActive to true
+      rerender({ isActive: true });
+
+      // Assert - Should load component
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not reload component when isActive changes from true to false', async () => {
+      // Arrange
+      const MockComponent = () => <div>Persistent</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Initial render with isActive=true
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('persistent-key', mockImport, isActive),
+        { initialProps: { isActive: true } },
+      );
+
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Act - Change isActive to false
+      rerender({ isActive: false });
+
+      // Assert - Component should remain loaded
+      expect(result.current).toBe(MockComponent);
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not reload component on multiple isActive=true rerenders', async () => {
+      // Arrange
+      const MockComponent = () => <div>Stable</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('stable-key', mockImport, isActive),
+        { initialProps: { isActive: true } },
+      );
+
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Act - Multiple rerenders
+      rerender({ isActive: true });
+      rerender({ isActive: true });
+
+      // Assert - Import should be called only once
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('Multiple instances', () => {
+    it('should handle multiple instances with different keys independently', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const Component3 = () => <div>Component3</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+      const mockImport3 = vi.fn().mockResolvedValue({ default: Component3 });
+
+      // Act
+      const { result: result1 } = renderHook(() => useLazyLoader('multi-key1', mockImport1, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('multi-key2', mockImport2, true));
+
+      const { result: result3 } = renderHook(() => useLazyLoader('multi-key3', mockImport3, false));
+
+      // Assert
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+        expect(result2.current).toBe(Component2);
+      });
+
+      expect(result3.current).toBeNull();
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).toHaveBeenCalledTimes(1);
+      expect(mockImport3).not.toHaveBeenCalled();
+    });
+
+    it('should handle concurrent loads with same key', async () => {
+      // Arrange
+      const MockComponent = () => <div>Concurrent</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Render two hooks with same key simultaneously
+      const { result: result1 } = renderHook(() => useLazyLoader('concurrent-key', mockImport, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('concurrent-key', mockImport, true));
+
+      // Assert - Both should resolve to same component
+      await waitFor(() => {
+        expect(result1.current).toBe(MockComponent);
+        expect(result2.current).toBe(MockComponent);
+      });
+
+      // Import should be called only once due to caching
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('Error handling', () => {
+    it('should handle import failure gracefully', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockError = new Error('Import failed');
+      const mockImport = vi.fn().mockRejectedValue(mockError);
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('error-key', mockImport, true));
+
+      // Assert - Should remain null on error
+      expect(result.current).toBeNull();
+
+      // Wait for error to be processed
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Component should still be null after error
+      expect(result.current).toBeNull();
+
+      consoleErrorSpy.mockRestore();
+    });
+  });
+
+  describe('Type safety', () => {
+    it('should work with components with props', async () => {
+      // Arrange
+      type TestProps = Record<string, unknown> & {
+        title: string;
+        count: number;
+      };
+      const MockComponentWithProps = ({ title, count }: TestProps) => (
+        <div>
+          {title}: {count}
+        </div>
+      );
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponentWithProps });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader<TestProps>('typed-key', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponentWithProps);
+      });
+    });
+  });
+
+  describe('Edge cases and boundary values', () => {
+    it('should handle empty string as importKey', async () => {
+      // Arrange
+      const MockComponent = () => <div>Empty Key</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should use first importFn when same key is used with different import functions', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+
+      // Act - First hook with Component1
+      const { result: result1 } = renderHook(() => useLazyLoader('duplicate-key', mockImport1, true));
+
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+      });
+
+      // Act - Second hook with same key but different import function
+      const { result: result2 } = renderHook(() => useLazyLoader('duplicate-key', mockImport2, true));
+
+      await waitFor(() => {
+        expect(result2.current).toBe(Component1); // Should still get Component1 from cache
+      });
+
+      // Assert - Only first import should be called
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).not.toHaveBeenCalled();
+    });
+
+    it('should handle import function returning null', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockImport = vi.fn().mockResolvedValue(null);
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('null-key', mockImport, true));
+
+      // Assert - Should remain null
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Wait a bit to ensure state update attempts have been processed
+      await new Promise(resolve => setTimeout(resolve, 50));
+
+      // Component should be null since the import resolved to null
+      expect(result.current).toBeNull();
+      expect(consoleErrorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('Module or default export is missing'),
+      );
+
+      consoleErrorSpy.mockRestore();
+    });
+
+    it('should handle import function returning object without default property', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockImport = vi.fn().mockResolvedValue({ notDefault: () => <div>Wrong</div> });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('no-default-key', mockImport, true));
+
+      // Assert - Should remain null since there's no default export
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Wait a bit to ensure state update attempts have been processed
+      await new Promise(resolve => setTimeout(resolve, 50));
+
+      expect(result.current).toBeNull();
+      expect(consoleErrorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('Module or default export is missing'),
+      );
+
+      consoleErrorSpy.mockRestore();
+    });
+
+    it('should handle rapid isActive toggling', async () => {
+      // Arrange
+      const MockComponent = () => <div>Toggled</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('toggle-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Rapidly toggle isActive
+      rerender({ isActive: true });
+      rerender({ isActive: false });
+      rerender({ isActive: true });
+      rerender({ isActive: false });
+      rerender({ isActive: true });
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Should only import once despite rapid toggling
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not call import function when isActive is false initially and remains false', async () => {
+      // Arrange
+      const mockImport = vi.fn().mockResolvedValue({ default: () => <div>Test</div> });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('inactive-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Multiple rerenders with isActive=false
+      rerender({ isActive: false });
+      rerender({ isActive: false });
+      rerender({ isActive: false });
+
+      // Wait a bit to ensure no async operations are triggered
+      await new Promise(resolve => setTimeout(resolve, 100));
+
+      // Assert
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+    });
+  });
+});

+ 82 - 0
apps/app/src/client/util/use-lazy-loader.ts

@@ -0,0 +1,82 @@
+import type React from 'react';
+import { useState, useEffect, useCallback } from 'react';
+
+type ComponentModule<T> = { default: React.ComponentType<T> };
+
+// Global cache for dynamically loaded components
+const componentCache = new Map<string, Promise<ComponentModule<unknown>>>();
+
+const getCachedImport = <T>(
+  key: string,
+  importFn: () => Promise<ComponentModule<T>>,
+): Promise<ComponentModule<T>> => {
+  if (!componentCache.has(key)) {
+    componentCache.set(key, importFn() as Promise<ComponentModule<unknown>>);
+  }
+  const cached = componentCache.get(key);
+  if (cached == null) {
+    throw new Error(`Failed to retrieve cached import for key: ${key}`);
+  }
+  return cached as Promise<ComponentModule<T>>;
+};
+
+/**
+ * Clears the component cache. This is primarily intended for testing purposes.
+ * In production, the cache persists for the lifetime of the application.
+ *
+ * @internal
+ */
+export const clearComponentCache = (): void => {
+  componentCache.clear();
+};
+
+/**
+ * Dynamically loads a component when it becomes active
+ *
+ * @param importKey - Unique identifier for the component (used for caching)
+ * @param importFn - Function that returns a dynamic import promise
+ * @param isActive - Whether the component should be loaded (e.g., modal open, tab selected, etc.)
+ * @returns The loaded component or null if not yet loaded
+ *
+ * @example
+ * // For modals
+ * const Modal = useLazyLoader('my-modal', () => import('./MyModal'), isOpen);
+ *
+ * @example
+ * // For tab content
+ * const TabContent = useLazyLoader('tab-advanced', () => import('./AdvancedTab'), activeTab === 'advanced');
+ *
+ * @example
+ * // For conditional panels
+ * const AdminPanel = useLazyLoader('admin-panel', () => import('./AdminPanel'), isAdmin);
+ */
+export const useLazyLoader = <T extends Record<string, unknown>>(
+  importKey: string,
+  importFn: () => Promise<{ default: React.ComponentType<T> }>,
+  isActive: boolean,
+): React.ComponentType<T> | null => {
+  const [Component, setComponent] = useState<React.ComponentType<T> | null>(null);
+
+  const memoizedImportFn = useCallback(importFn, [importFn, importKey]);
+
+  useEffect(() => {
+    if (isActive && !Component) {
+      getCachedImport(importKey, memoizedImportFn)
+        .then((mod) => {
+          if (mod?.default) {
+            setComponent(() => mod.default);
+          }
+          else {
+            // eslint-disable-next-line no-console
+            console.error(`Failed to load component with key "${importKey}": Module or default export is missing`);
+          }
+        })
+        .catch((error) => {
+          // eslint-disable-next-line no-console
+          console.error(`Failed to load component with key "${importKey}":`, error);
+        });
+    }
+  }, [isActive, Component, importKey, memoizedImportFn]);
+
+  return Component;
+};