diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9e99c4..3f87e38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -983,6 +983,11 @@ function App() { const barsMarket = useUpbit ? upbitBarsMarket : symbol; barsMarketRef.current = barsMarket; + /** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 */ + useEffect(() => { + pendingRealtimeBarRef.current = null; + }, [symbol, timeframe]); + // Upbit 초기 로딩 완료 시 stats 업데이트 useEffect(() => { if (useUpbit && upbitBars.length > 0) { @@ -1092,8 +1097,11 @@ function App() { noAutoSave: !dbLoaded, }); - /** 로그인·로그아웃 시에만 차트 재마운트 (boot/ready·심볼 변경은 TradingChart effect로 처리) */ - const chartMountKey = useMemo(() => String(sessionKey), [sessionKey]); + /** 로그인·로그아웃·종목·타임프레임 변경 시 차트 재마운트 (종목 전환 직후 stale 데이터·중단된 reload 방지) */ + const chartMountKey = useMemo( + () => `${sessionKey}:${symbol}:${timeframe}`, + [sessionKey, symbol, timeframe], + ); // DB에 워크스페이스 없음(204) → appDefaults 레이아웃 적용 후 저장 허용 useEffect(() => { diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 0ae4269..9d0593b 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -380,6 +380,10 @@ const ChartSlot = forwardRef(function ChartSlot // 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard) const [reloadTick, setReloadTick] = useState(0); const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회 + useEffect(() => { + reloadTriggeredRef.current = false; + pendingRealtimeBarRef.current = null; + }, [symbol, timeframe]); useEffect(() => { const checks = [200, 500, 1000, 2000].map(delay => setTimeout(() => { @@ -626,7 +630,7 @@ const ChartSlot = forwardRef(function ChartSlot )} = ({ inds: IndicatorConfig[], ) => Promise>(async () => {}); + const queueFullReload = useCallback(() => { + if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); + recoveryTimerRef.current = window.setTimeout(() => { + recoveryTimerRef.current = null; + const m = managerRef.current; + const barData = barsRef.current; + const inds = indicatorsRef.current; + if (!m || barData.length === 0) return; + if (barsMarket !== undefined && barsMarket !== market) return; + if (market.startsWith('KRW-') && barData.length < MIN_BARS_FOR_FULL_RELOAD) return; + prevBarsKey.current = ''; + reloadAllRef.current?.( + m, barData, + prevChartType.current, prevTheme.current, prevLogScale.current, + inds, + ); + }, 80); + }, [market, barsMarket]); + const queueIndicatorRecovery = useCallback(() => { if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); recoveryTimerRef.current = window.setTimeout(() => { @@ -329,7 +351,7 @@ const TradingChart: React.FC = ({ mgr.setData(newBars, ct, th); if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; - queueIndicatorRecovery(); + queueFullReload(); return; } mgr.setTheme(th); @@ -339,7 +361,7 @@ const TradingChart: React.FC = ({ for (const ind of inds) { if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; - queueIndicatorRecovery(); + queueFullReload(); return; } await mgr.addIndicator(ind); @@ -347,7 +369,7 @@ const TradingChart: React.FC = ({ if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; - queueIndicatorRecovery(); + queueFullReload(); return; } @@ -360,7 +382,7 @@ const TradingChart: React.FC = ({ reloadRetryRef.current += 1; await mgr.reloadIndicatorsOnly(inds); if (gen !== reloadGenRef.current) { - queueIndicatorRecovery(); + queueFullReload(); return; } } @@ -392,7 +414,7 @@ const TradingChart: React.FC = ({ if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; - queueIndicatorRecovery(); + queueFullReload(); return; } @@ -416,7 +438,7 @@ const TradingChart: React.FC = ({ if (delay === 1400) onDataLoaded?.(); }, delay) ); - }, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueIndicatorRecovery]); + }, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload]); useEffect(() => { reloadAllRef.current = reloadAll; @@ -740,6 +762,7 @@ const TradingChart: React.FC = ({ ? true : barsMarket === market; if (!barsReady) return; + if (market.startsWith('KRW-') && bars.length < MIN_BARS_FOR_FULL_RELOAD) return; // manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지 if (!mgr.hasMainSeries()) { diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index 6f0dd71..cad7e3e 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -157,6 +157,7 @@ export function useStompChartData( }, [market, candleType, opts.enabled]); const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => { + if (!historyReadyRef.current) return; const oneMin: OHLCVBar = { time: data.time, open: data.open,