From 08b5d772aef8611d1c3356753debf192f4ccc80f Mon Sep 17 00:00:00 2001 From: zy2000-star <2000z1013y@gmail.com> Date: Mon, 7 Sep 2026 22:06:15 +0800 Subject: [PATCH 1/2] fix: default closed stock markets to token price OK-61733 --- .../hooks/useStockPriceSource.test.tsx | 102 ++++++++++++++++++ .../hooks/useStockPriceSource.ts | 44 ++++++++ .../layouts/StockDesktopLayout.tsx | 21 +--- 3 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.test.tsx create mode 100644 packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.ts diff --git a/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.test.tsx b/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.test.tsx new file mode 100644 index 000000000000..4a201c7aa684 --- /dev/null +++ b/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.test.tsx @@ -0,0 +1,102 @@ +/** @jest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; + +import type { IMarketPriceSource } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; + +import { useStockPriceSource } from './useStockPriceSource'; + +let mockStock: { + stockId: string; + stockDetail?: { marketStatus?: { isOpen: boolean } }; +}; + +jest.mock('./StockDetailContext', () => ({ + useStockDetail: () => mockStock, +})); + +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + useMarketPriceSourceAtom: () => { + const { useState } = jest.requireActual('react'); + return useState<{ source: IMarketPriceSource }>({ source: 'share' }); + }, +})); + +describe('useStockPriceSource', () => { + beforeEach(() => { + mockStock = { stockId: 'AAPL' }; + }); + + it.each([ + [false, 'token'], + [true, 'share'], + ] as const)('defaults isOpen=%s to %s after loading', (isOpen, source) => { + const { result, rerender } = renderHook(() => useStockPriceSource()); + expect(result.current.priceMode).toBe('share'); + + mockStock.stockDetail = { marketStatus: { isOpen } }; + rerender(); + + expect(result.current.priceMode).toBe(source); + }); + + it('uses an already loaded closed-market status on mount', () => { + mockStock.stockDetail = { marketStatus: { isOpen: false } }; + const { result } = renderHook(() => useStockPriceSource()); + expect(result.current.priceMode).toBe('token'); + }); + + it('keeps Share Price when the market status is missing', () => { + mockStock.stockDetail = {}; + const { result } = renderHook(() => useStockPriceSource()); + expect(result.current.priceMode).toBe('share'); + }); + + it.each([ + [true, 'token'], + [false, 'share'], + ] as const)( + 'preserves manual %s -> %s through polling', + (isInitiallyOpen, source) => { + mockStock.stockDetail = { marketStatus: { isOpen: isInitiallyOpen } }; + const { result, rerender } = renderHook(() => useStockPriceSource()); + act(() => result.current.handlePriceModeChange(source)); + + for (const isOpen of [true, false, true]) { + mockStock.stockDetail = { marketStatus: { isOpen } }; + rerender(); + expect(result.current.priceMode).toBe(source); + } + }, + ); + + it('preserves a manual Share Price choice before the response arrives', () => { + const { result, rerender } = renderHook(() => useStockPriceSource()); + act(() => result.current.handlePriceModeChange('share')); + + mockStock.stockDetail = { marketStatus: { isOpen: false } }; + rerender(); + expect(result.current.priceMode).toBe('share'); + }); + + it('reinitializes on stock changes, including returning to a previous stock', () => { + mockStock.stockDetail = { marketStatus: { isOpen: false } }; + const { result, rerender } = renderHook(() => useStockPriceSource()); + expect(result.current.priceMode).toBe('token'); + act(() => result.current.handlePriceModeChange('share')); + + mockStock = { stockId: 'MSFT' }; + rerender(); + expect(result.current.priceMode).toBe('share'); + mockStock.stockDetail = { marketStatus: { isOpen: true } }; + rerender(); + expect(result.current.priceMode).toBe('share'); + + mockStock = { + stockId: 'AAPL', + stockDetail: { marketStatus: { isOpen: false } }, + }; + rerender(); + expect(result.current.priceMode).toBe('token'); + }); +}); diff --git a/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.ts b/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.ts new file mode 100644 index 000000000000..f1bbd1da82c1 --- /dev/null +++ b/packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { + type IMarketPriceSource, + useMarketPriceSourceAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; + +import { useStockDetail } from './StockDetailContext'; + +export function useStockPriceSource() { + const { stockId, stockDetail } = useStockDetail(); + const isOpen = stockDetail?.marketStatus?.isOpen; + const [{ source: priceMode }, setPriceSource] = useMarketPriceSourceAtom(); + const initializedRef = useRef(false); + + useEffect(() => { + initializedRef.current = false; + setPriceSource((prev) => + prev.source === 'share' ? prev : { source: 'share' }, + ); + }, [stockId, setPriceSource]); + + useEffect(() => { + // Wait for this stock's market status, then apply its default only once. + // Quote polling must not replace the user's choice on the same stock. + if (!stockId || initializedRef.current || typeof isOpen !== 'boolean') { + return; + } + initializedRef.current = true; + const source = isOpen ? 'share' : 'token'; + setPriceSource((prev) => (prev.source === source ? prev : { source })); + }, [isOpen, stockId, setPriceSource]); + + const handlePriceModeChange = useCallback( + (source: IMarketPriceSource) => { + // A manual choice made before the first response also takes precedence. + initializedRef.current = true; + setPriceSource({ source }); + }, + [setPriceSource], + ); + + return { priceMode, handlePriceModeChange }; +} diff --git a/packages/kit/src/views/Market/MarketDetailV2/layouts/StockDesktopLayout.tsx b/packages/kit/src/views/Market/MarketDetailV2/layouts/StockDesktopLayout.tsx index 64442caf7895..53d112a6bd8b 100644 --- a/packages/kit/src/views/Market/MarketDetailV2/layouts/StockDesktopLayout.tsx +++ b/packages/kit/src/views/Market/MarketDetailV2/layouts/StockDesktopLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import BigNumber from 'bignumber.js'; @@ -24,7 +24,6 @@ import { type IMarketDetailChartDisplayMode, type IMarketPriceSource, useMarketDetailChartDisplayModePersistAtom, - useMarketPriceSourceAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { EWatchlistFrom } from '@onekeyhq/shared/src/logger/scopes/dex'; @@ -56,6 +55,7 @@ import { ShareButton } from '../components/TokenDetailHeader/ShareButton'; import { MarketTokenSelector } from '../components/TokenSelector/MarketTokenSelector'; import { useStockDetail } from '../hooks/StockDetailContext'; import { useStockPortfolioData } from '../hooks/useStockPortfolioData'; +import { useStockPriceSource } from '../hooks/useStockPriceSource'; import { useTokenDetail } from '../hooks/useTokenDetail'; import { STAT_FALLBACK_VALUE, @@ -1181,27 +1181,12 @@ export function StockDesktopLayout({ // control row hands its trailing slots to this page's stable overlay. onEnterChartFullscreen: () => void; }) { - const { stockId } = useStockDetail(); const { portfolioData: stockPortfolioData, isRefreshing: isStockPortfolioRefreshing, hasAccount: hasStockPortfolioAccount, } = useStockPortfolioData(); - const [{ source: priceMode }, setPriceSource] = useMarketPriceSourceAtom(); - const handlePriceModeChange = useCallback( - (source: IMarketPriceSource) => setPriceSource({ source }), - [setPriceSource], - ); - // The price source atom is global and outlives this page, so a Token Price - // selection would otherwise leak into the next stock opened. Every per-stock - // entry resets to the share price the page is named after. Keyed on stockId - // only (never on priceMode) so switching the toggle within one stock does not - // re-trigger the reset. - useEffect(() => { - setPriceSource((prev) => - prev.source === 'share' ? prev : { source: 'share' }, - ); - }, [stockId, setPriceSource]); + const { priceMode, handlePriceModeChange } = useStockPriceSource(); // Lives here rather than inside the chart so the price header above it can // follow the crosshair; the chart clears it on pointer-out and on unmount. const [chartHoverPoint, setChartHoverPoint] = useState< From 683de41422ae32a9aed17c206b3f0f90e5f1f274 Mon Sep 17 00:00:00 2001 From: zy2000-star <2000z1013y@gmail.com> Date: Mon, 7 Sep 2026 23:54:36 +0800 Subject: [PATCH 2/2] fix: register stock price source module OK-61733 --- apps/mobile/bundle-registry/module-id-registry.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/bundle-registry/module-id-registry.json b/apps/mobile/bundle-registry/module-id-registry.json index a1676ce70dec..704b5c2ecc09 100644 --- a/apps/mobile/bundle-registry/module-id-registry.json +++ b/apps/mobile/bundle-registry/module-id-registry.json @@ -21621,6 +21621,7 @@ "packages/kit/src/views/Market/MarketDetailV2/hooks/useMarketHolders.ts": 921, "packages/kit/src/views/Market/MarketDetailV2/hooks/useMarketHolders.utils.ts": 8392, "packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPortfolioData.ts": 2206, + "packages/kit/src/views/Market/MarketDetailV2/hooks/useStockPriceSource.ts": 5374, "packages/kit/src/views/Market/MarketDetailV2/hooks/useStockSecurityStats.ts": 19955, "packages/kit/src/views/Market/MarketDetailV2/hooks/useTokenDetail.ts": 21686, "packages/kit/src/views/Market/MarketDetailV2/hooks/useTopCoinsDetail.ts": 22071,