Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/bundle-registry/module-id-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('react')>('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');
});
});
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: [Returning to a stacked stock page skips reinitialization]

When a desktop user opens stock A, navigates to stock B, and then pops back, A can keep B’s price source. Desktop detail screens remain mounted in the navigation stack, so A’s hook instance still has initializedRef.current === true, while B has written to the shared global price-source atom.

Because returning to A changes neither A’s stockId nor its isOpen value, these effects do not reinitialize it. A closed stock can therefore come back showing the stale Share Price header and chart instead of Token Price.

Please make route focus the ownership boundary: reset and apply the focused stock’s current default on each focus epoch, while preventing blurred route instances from writing the global atom. A regression test should mount two route instances sharing the atom and switch focus between them.


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 };
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<
Expand Down
Loading