diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js index ecefbadfa..282f69e98 100644 --- a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js @@ -4,6 +4,7 @@ import { uuid } from 'utils/common'; import { useFormik } from 'formik'; import { variableNameRegex } from 'utils/common/regex'; import toast from 'react-hot-toast'; +import useDeferredLoading from 'hooks/useDeferredLoading'; import StyledWrapper from './StyledWrapper'; import DotEnvTableView from './DotEnvTableView'; @@ -31,6 +32,7 @@ const DotEnvFileEditor = ({ const [rawValue, setRawValue] = useState(initialRawValue); const [prevViewMode, setPrevViewMode] = useState(viewMode); const [isSaving, setIsSaving] = useState(false); + const showSaving = useDeferredLoading(isSaving, 200); const formikRef = useRef(null); @@ -311,7 +313,7 @@ const DotEnvFileEditor = ({ onChange={handleRawChange} onSave={handleSaveRaw} onReset={handleReset} - isSaving={isSaving} + isSaving={showSaving} /> ); @@ -335,7 +337,7 @@ const DotEnvFileEditor = ({ onRemoveVar={handleRemoveVar} onSave={handleSave} onReset={handleReset} - isSaving={isSaving} + isSaving={showSaving} /> ); diff --git a/packages/bruno-app/src/hooks/useDeferredLoading/index.js b/packages/bruno-app/src/hooks/useDeferredLoading/index.js new file mode 100644 index 000000000..69d0f4883 --- /dev/null +++ b/packages/bruno-app/src/hooks/useDeferredLoading/index.js @@ -0,0 +1,39 @@ +import { useState, useEffect, useRef } from 'react'; + +/** + * A hook that defers showing loading state until a minimum delay has passed. + * This prevents flickering UI for fast operations. + * + * @param {boolean} isLoading - The actual loading state + * @param {number} delay - Minimum time (ms) before showing loading state (default: 200ms) + * @returns {boolean} - The deferred loading state + */ +function useDeferredLoading(isLoading, delay = 200) { + const [showLoading, setShowLoading] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + if (isLoading) { + timerRef.current = setTimeout(() => { + setShowLoading(true); + }, delay); + } else { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setShowLoading(false); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [isLoading, delay]); + + return showLoading; +} + +export default useDeferredLoading; diff --git a/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js b/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js new file mode 100644 index 000000000..92761f9c7 --- /dev/null +++ b/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js @@ -0,0 +1,109 @@ +const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals'); +import { renderHook, act } from '@testing-library/react'; +import useDeferredLoading from './index'; + +describe('useDeferredLoading', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return false initially when isLoading is false', () => { + const { result } = renderHook(() => useDeferredLoading(false)); + expect(result.current).toBe(false); + }); + + it('should not show loading immediately when isLoading becomes true', () => { + const { result } = renderHook(() => useDeferredLoading(true, 200)); + expect(result.current).toBe(false); + }); + + it('should show loading after the delay has passed', () => { + const { result } = renderHook(() => useDeferredLoading(true, 200)); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(true); + }); + + it('should not show loading if isLoading becomes false before delay', () => { + const { result, rerender } = renderHook( + ({ isLoading }) => useDeferredLoading(isLoading, 200), + { initialProps: { isLoading: true } } + ); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(result.current).toBe(false); + + rerender({ isLoading: false }); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(false); + }); + + it('should reset to false immediately when isLoading becomes false', () => { + const { result, rerender } = renderHook( + ({ isLoading }) => useDeferredLoading(isLoading, 200), + { initialProps: { isLoading: true } } + ); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(true); + + rerender({ isLoading: false }); + + expect(result.current).toBe(false); + }); + + it('should use default delay of 200ms', () => { + const { result } = renderHook(() => useDeferredLoading(true)); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(199); + }); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(result.current).toBe(true); + }); + + it('should respect custom delay values', () => { + const { result } = renderHook(() => useDeferredLoading(true, 500)); + + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(result.current).toBe(true); + }); +});