From abf7f654af5ce9fc8a5ca7e6f0f13971d080e417 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 15:44:11 +0900 Subject: [PATCH] improve: refactor AppLoadingProgress for better UX and maintainability - Extract nextProgress() pure function for asymptotic easing logic - Extract useLoadingProgress() hook for phase/timer lifecycle management - Add 150ms delay to skip showing bar for fast navigations - Fix timer leak: cleanup all setTimeout/setInterval on unmount - Visual improvements: blue-500 color, shimmer effect, thinner bar, z-50 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/AppLoadingProgress.tsx | 128 ++++++++++++++++++++------ 1 file changed, 99 insertions(+), 29 deletions(-) diff --git a/app/components/AppLoadingProgress.tsx b/app/components/AppLoadingProgress.tsx index 1ece0eb1..fb56f2e3 100644 --- a/app/components/AppLoadingProgress.tsx +++ b/app/components/AppLoadingProgress.tsx @@ -1,44 +1,114 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigation } from 'react-router' +import { cn } from '~/app/libs/utils' -export const AppLoadingProgress = () => { +/** + * Compute next progress value with asymptotic easing: + * fast start → gradual slowdown → stall near 90%. + */ +function nextProgress(current: number): number { + if (current < 30) return current + Math.random() * 4 + 2 + if (current < 60) return current + Math.random() * 2 + 0.5 + if (current < 85) return current + Math.random() * 0.5 + return current // stall — wait for completion +} + +/** Skip showing the bar for navigations faster than this threshold */ +const SHOW_DELAY_MS = 150 + +type Phase = 'idle' | 'waiting' | 'loading' | 'completing' + +/** + * Manages the trickle progress lifecycle: + * idle → waiting (delay) → loading (trickle toward ~85%) → completing (snap to 100%, fade out) → idle + * If navigation finishes during waiting, the bar is never shown. + */ +function useLoadingProgress() { const navigation = useNavigation() - const isLoading = navigation.state !== 'idle' - const [value, setValue] = useState(0) + const isNavigating = navigation.state !== 'idle' + + const [phase, setPhase] = useState('idle') + const [progress, setProgress] = useState(0) + const phaseRef = useRef('idle') + const delayRef = useRef(0) + const intervalRef = useRef(0) + const fadeRef = useRef(0) useEffect(() => { - let interval: number | null = null - if (isLoading) { - setValue(0) - interval = window.setInterval(() => { - // 演出 - setValue((v) => { - if (v < 50) return v + Math.random() * 2 - if (v < 90) return v + 1 - return v - }) - }, 100) + if (isNavigating) { + // Cancel any in-flight fade-out from a previous navigation + window.clearTimeout(fadeRef.current) + + // Enter waiting phase — delay before showing the bar + phaseRef.current = 'waiting' + setPhase('waiting') + setProgress(0) + + delayRef.current = window.setTimeout(() => { + if (phaseRef.current !== 'waiting') return + // Promote to loading — start trickle + phaseRef.current = 'loading' + setPhase('loading') + intervalRef.current = window.setInterval(() => { + setProgress((v) => nextProgress(v)) + }, 150) + }, SHOW_DELAY_MS) + } else if ( + phaseRef.current === 'waiting' || + phaseRef.current === 'loading' + ) { + // Navigation finished + window.clearTimeout(delayRef.current) + window.clearInterval(intervalRef.current) + + if (phaseRef.current === 'waiting') { + // Fast navigation — never show the bar + phaseRef.current = 'idle' + setPhase('idle') + } else { + // Was visible — snap to 100% and fade out + phaseRef.current = 'completing' + setPhase('completing') + setProgress(100) + fadeRef.current = window.setTimeout(() => { + phaseRef.current = 'idle' + setPhase('idle') + setProgress(0) + }, 400) + } } return () => { - if (interval) { - clearInterval(interval) - setValue(100) - setTimeout(() => { - setValue(0) - }, 500) - } + window.clearTimeout(delayRef.current) + window.clearInterval(intervalRef.current) + window.clearTimeout(fadeRef.current) } - }, [isLoading]) + }, [isNavigating]) + + return { phase, progress } +} + +export const AppLoadingProgress = () => { + const { phase, progress } = useLoadingProgress() + + if (phase === 'idle' || phase === 'waiting') return null return ( -
+
+ className={cn( + 'h-full transition-all ease-out', + phase === 'completing' + ? 'opacity-0 duration-400' + : 'opacity-100 duration-200', + )} + style={{ width: `${progress}%` }} + > + {/* Bar with shimmer effect */} +
+
+
+
) }