diff --git a/packages/core/index.ts b/packages/core/index.ts index 7a98fe414a1..8b03ba322ed 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -71,6 +71,7 @@ export * from './useMediaControls' export * from './useMediaQuery' export * from './useMemoize' export * from './useMemory' +export * from './useMicroLoader' export * from './useMounted' export * from './useMouse' export * from './useMouseInElement' diff --git a/packages/core/useMicroLoader/components/CustomLoaderDemo.vue b/packages/core/useMicroLoader/components/CustomLoaderDemo.vue new file mode 100644 index 00000000000..6f3759e9a2b --- /dev/null +++ b/packages/core/useMicroLoader/components/CustomLoaderDemo.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/packages/core/useMicroLoader/demo.vue b/packages/core/useMicroLoader/demo.vue new file mode 100644 index 00000000000..ff85b2107e1 --- /dev/null +++ b/packages/core/useMicroLoader/demo.vue @@ -0,0 +1,103 @@ + + + diff --git a/packages/core/useMicroLoader/index.md b/packages/core/useMicroLoader/index.md new file mode 100644 index 00000000000..ff6fa64a989 --- /dev/null +++ b/packages/core/useMicroLoader/index.md @@ -0,0 +1,87 @@ +--- +category: State +--- + +# useMicroLoader + +A composable that provides a smart loading state management with micro-loading detection. It helps prevent UI flicker for quick operations while ensuring a minimum loading time for better user experience. Particularly useful for handling both fast and slow network conditions, preventing unnecessary loading indicators on fast connections. + +## Usage + +```ts +import { useMicroLoader } from '@vueuse/core' + +const isLoading = useMicroLoader(() => asyncTaskStatus.value === 'pending') +``` + +## Features + +- Prevents UI flicker for quick operations (under `quickLoadingThresholdMs`) +- Ensures a minimum loading time for better UX +- Adapts to network conditions - won't show loading indicators on fast connections +- Handles both local operations and network requests efficiently + +## Parameters + +- `isLoadingRef`: A reactive reference or getter that indicates the loading state +- `options`: Optional configuration object + - `minLoadingTimeMs`: Minimum duration (in milliseconds) that the loading state should be shown (default: 500ms) + - `quickLoadingThresholdMs`: Time threshold (in milliseconds) below which loading state won't be shown (default: 300ms) + +## Return Value + +Returns a reactive reference that indicates whether the micro-loading state is active. + +## Examples + +### Basic Usage + +```ts +const isLoading = ref(false) +const isMicroLoading = useMicroLoader(isLoading) +``` + +### Custom Configuration + +```ts +const isLoading = ref(false) +const isMicroLoading = useMicroLoader(isLoading, { + minLoadingTimeMs: 1000, // Show loading for at least 1 second + quickLoadingThresholdMs: 500 // Don't show loading for operations under 500ms +}) +``` + +### With Async Operations + +```ts +const taskStatus = ref<'idle' | 'pending' | 'done'>('idle') +const isMicroLoading = useMicroLoader(() => taskStatus.value === 'pending') + +async function performTask() { + taskStatus.value = 'pending' + await someAsyncOperation() + taskStatus.value = 'done' +} +``` + +### Network-Aware Loading + +```ts +const taskStatus = ref<'idle' | 'pending' | 'done'>('idle') +const isMicroLoading = useMicroLoader(() => taskStatus.value === 'pending', { + // On fast networks, don't show loading for operations under 1 second + quickLoadingThresholdMs: 1000, + // Ensure loading state is shown for at least 500ms on slow networks + minLoadingTimeMs: 500, +}) + +async function fetchData() { + taskStatus.value = 'pending' + try { + await fetch('/api/data') + } + finally { + taskStatus.value = 'done' + } +} +``` diff --git a/packages/core/useMicroLoader/index.test.ts b/packages/core/useMicroLoader/index.test.ts new file mode 100644 index 00000000000..27786ab222a --- /dev/null +++ b/packages/core/useMicroLoader/index.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, shallowRef } from 'vue' +import { useMicroLoader } from './index' + +describe('useMicroLoader', () => { + let systemTime: Date + beforeEach(() => { + vi.useFakeTimers() + systemTime = new Date('2025-01-01') + vi.setSystemTime(systemTime) + }) + + function advanceSystemTime(ms: number) { + systemTime = new Date(systemTime.getTime() + ms) + vi.setSystemTime(systemTime) + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('should not show micro loading for quick operations', async () => { + const isLoadingRef = shallowRef(false) + const isMicroLoadingRef = useMicroLoader(isLoadingRef, { + minLoadingTimeMs: 10_000, + quickLoadingThresholdMs: 5_000, + }) + + // Start loading + isLoadingRef.value = true + await nextTick() + + // Advance time just before the quick loading threshold + vi.advanceTimersByTime(1000) + advanceSystemTime(1000) + + // Micro loading should not be shown yet + expect(isMicroLoadingRef.value).toBe(false) + + // End loading before the quick loading threshold + isLoadingRef.value = false + await nextTick() + + vi.advanceTimersByTime(1000) + advanceSystemTime(1000) + + // Micro loading should remain false + expect(isMicroLoadingRef.value).toBe(false) + }) + + it('should show micro loading for slow operations', async () => { + const isLoadingRef = shallowRef(false) + const isMicroLoadingRef = useMicroLoader(isLoadingRef, { + minLoadingTimeMs: 10_000, + quickLoadingThresholdMs: 5_000, + }) + + // Start loading + isLoadingRef.value = true + await nextTick() + + // Advance time past the quick loading threshold + vi.advanceTimersByTime(5001) + advanceSystemTime(5001) + // Micro loading should be shown + expect(isMicroLoadingRef.value).toBe(true) + + // End loading + isLoadingRef.value = false + await nextTick() + + // Micro loading should remain true for the minimum loading time + expect(isMicroLoadingRef.value).toBe(true) + + // Advance time to just before the minimum loading time + vi.advanceTimersByTime(4998) + advanceSystemTime(4998) + + // Micro loading should still be true + expect(isMicroLoadingRef.value).toBe(true) + + // Advance time past the minimum loading time + vi.advanceTimersByTime(1) + advanceSystemTime(1) + + // Micro loading should now be false + expect(isMicroLoadingRef.value).toBe(false) + }) + + it('should handle multiple loading cycles correctly', async () => { + const isLoadingRef = shallowRef(false) + const isMicroLoadingRef = useMicroLoader(isLoadingRef, { + minLoadingTimeMs: 10_000, + quickLoadingThresholdMs: 5_000, + }) + + // First loading cycle - quick + isLoadingRef.value = true + await nextTick() + + vi.advanceTimersByTime(4999) + advanceSystemTime(4999) + + isLoadingRef.value = false + await nextTick() + + expect(isMicroLoadingRef.value).toBe(false) + + // Second loading cycle - slow + isLoadingRef.value = true + await nextTick() + + vi.advanceTimersByTime(5001) + advanceSystemTime(5001) + + expect(isMicroLoadingRef.value).toBe(true) + isLoadingRef.value = false + await nextTick() + + // Micro loading should remain true for the minimum loading time + expect(isMicroLoadingRef.value).toBe(true) + vi.advanceTimersByTime(4998) + advanceSystemTime(4998) + expect(isMicroLoadingRef.value).toBe(true) + + vi.advanceTimersByTime(1) + advanceSystemTime(1) + + expect(isMicroLoadingRef.value).toBe(false) + + // Third loading cycle - quick again + isLoadingRef.value = true + await nextTick() + + vi.advanceTimersByTime(4999) + advanceSystemTime(4999) + isLoadingRef.value = false + await nextTick() + + expect(isMicroLoadingRef.value).toBe(false) + }) + + it('should handle rapid toggling of loading state', () => { + const isLoadingRef = shallowRef(false) + const isMicroLoadingRef = useMicroLoader(isLoadingRef, { + minLoadingTimeMs: 10_000, + quickLoadingThresholdMs: 5_000, + }) + + // Rapidly toggle loading state + for (let i = 0; i < 5; i++) { + isLoadingRef.value = true + vi.advanceTimersByTime(2500) + advanceSystemTime(2500) + isLoadingRef.value = false + vi.advanceTimersByTime(2500) + advanceSystemTime(2500) + } + + // Micro loading should not be shown for these quick operations + expect(isMicroLoadingRef.value).toBe(false) + }) + + it('should handle loading state changes while micro loading is active', async () => { + const isLoadingRef = shallowRef(false) + const isMicroLoadingRef = useMicroLoader(isLoadingRef, { + minLoadingTimeMs: 10_000, + quickLoadingThresholdMs: 5_000, + }) + + // Start loading + isLoadingRef.value = true + await nextTick() + + // Advance time past the quick loading threshold + vi.advanceTimersByTime(5001) + advanceSystemTime(5001) + // Micro loading should be shown + expect(isMicroLoadingRef.value).toBe(true) + + // Change loading state while micro loading is active + isLoadingRef.value = false + await nextTick() + // Micro loading should remain true for the minimum loading time + expect(isMicroLoadingRef.value).toBe(true) + + // Advance time past the minimum loading time + vi.advanceTimersByTime(10000) + advanceSystemTime(10000) + // Micro loading should now be false + expect(isMicroLoadingRef.value).toBe(false) + }) +}) diff --git a/packages/core/useMicroLoader/index.ts b/packages/core/useMicroLoader/index.ts new file mode 100644 index 00000000000..24c6a80bcd1 --- /dev/null +++ b/packages/core/useMicroLoader/index.ts @@ -0,0 +1,57 @@ +import type { MaybeRefOrGetter } from 'vue' +import { shallowRef, toValue, watchEffect } from 'vue' + +const MIN_LOADING_TIME_MS = 500 +const QUICK_LOADING_THRESHOLD_MS = 300 + +export function useMicroLoader( + isLoadingRef: MaybeRefOrGetter, + { + minLoadingTimeMs = MIN_LOADING_TIME_MS, + quickLoadingThresholdMs = QUICK_LOADING_THRESHOLD_MS, + }: { + minLoadingTimeMs?: number + quickLoadingThresholdMs?: number + } = {}, +) { + const isMicroLoadingRef = shallowRef(false) + let loadingStartTime: number | null = null + let loadingTimeout: number | null = null + + watchEffect(() => { + const newValue = toValue(isLoadingRef) + + if (newValue) { + loadingStartTime = Date.now() + + loadingTimeout = setTimeout(() => { + isMicroLoadingRef.value = true + }, quickLoadingThresholdMs) as unknown as number + } + else { + if (loadingTimeout) { + clearTimeout(loadingTimeout) + loadingTimeout = null + } + + if (loadingStartTime) { + const loadingDuration = Date.now() - loadingStartTime + + if (loadingDuration < quickLoadingThresholdMs) { + isMicroLoadingRef.value = false + } + else { + const remainingTime = Math.max(0, minLoadingTimeMs - loadingDuration) + + setTimeout(() => { + isMicroLoadingRef.value = false + }, remainingTime) + } + } + + loadingStartTime = null + } + }) + + return isMicroLoadingRef +}