From 73feec8f4b166ef78d22a601562e483168637077 Mon Sep 17 00:00:00 2001 From: weatherstar Date: Mon, 7 Sep 2026 10:23:17 +0800 Subject: [PATCH 1/9] fix: close tooltips on trigger press and modal push A hover tooltip stayed open above a freshly pushed modal (desktop home "Copy address" -> WalletAddress modal) because Tamagui tooltips only close on mouseleave, the pointer does not move when a modal appears, and the tooltip portal always paints above modals. - Tooltip (web): a mouse/pen pointerdown on the trigger closes the tooltip and keeps it closed until the pointer leaves the trigger. Touch is exempt because a tap is the only way to reveal a tooltip there. Uses pointerdown instead of onPressIn, which fires for both mousedown and touchstart. - tooltipRegistry: track open tooltips and expose closeAllTooltips(); useAppNavigation.pushModalPage calls it so shortcut- or event-driven modals never open under a lingering tooltip. - Unit tests for the registry, the open-state hook, and the navigation hook; desktop verified over CDP. --- .../src/actions/Tooltip/index.native.tsx | 1 + .../components/src/actions/Tooltip/index.tsx | 131 +++---------- .../actions/Tooltip/tooltipRegistry.test.ts | 38 ++++ .../src/actions/Tooltip/tooltipRegistry.ts | 16 ++ .../Tooltip/useTooltipOpenState.test.tsx | 138 ++++++++++++++ .../actions/Tooltip/useTooltipOpenState.ts | 175 ++++++++++++++++++ .../components/src/actions/index.web-only.ts | 1 + .../useAppNavigation.closeTooltips.test.tsx | 89 +++++++++ packages/kit/src/hooks/useAppNavigation.ts | 4 + 9 files changed, 491 insertions(+), 102 deletions(-) create mode 100644 packages/components/src/actions/Tooltip/tooltipRegistry.test.ts create mode 100644 packages/components/src/actions/Tooltip/tooltipRegistry.ts create mode 100644 packages/components/src/actions/Tooltip/useTooltipOpenState.test.tsx create mode 100644 packages/components/src/actions/Tooltip/useTooltipOpenState.ts create mode 100644 packages/kit/src/hooks/useAppNavigation.closeTooltips.test.tsx diff --git a/packages/components/src/actions/Tooltip/index.native.tsx b/packages/components/src/actions/Tooltip/index.native.tsx index 1623846dff77..698fb4a13f21 100644 --- a/packages/components/src/actions/Tooltip/index.native.tsx +++ b/packages/components/src/actions/Tooltip/index.native.tsx @@ -9,4 +9,5 @@ export function Tooltip({ renderTrigger }: ITooltipProps) { Tooltip.Text = TooltipText; export * from './context'; +export { closeAllTooltips } from './tooltipRegistry'; export * from './type'; diff --git a/packages/components/src/actions/Tooltip/index.tsx b/packages/components/src/actions/Tooltip/index.tsx index b44148c0d1b6..894d02efaf84 100644 --- a/packages/components/src/actions/Tooltip/index.tsx +++ b/packages/components/src/actions/Tooltip/index.tsx @@ -1,10 +1,4 @@ -import { - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { useImperativeHandle, useMemo } from 'react'; import { TMTooltip } from '@onekeyhq/components/src/shared/tamaguiOverlay'; import type { PopoverContentProps } from '@onekeyhq/components/src/shared/tamaguiOverlay'; @@ -13,6 +7,7 @@ import { ANIMATE_ONLY_OPACITY_TRANSFORM } from '../../utils/animationConstants'; import { TooltipContext } from './context'; import { TooltipText } from './TooltipText'; +import { useTooltipOpenState } from './useTooltipOpenState'; import type { ITooltipProps } from './type'; @@ -20,43 +15,6 @@ const tooltipEnterStyle = { scale: 0.95, opacity: 0 } as const; const tooltipExitStyle = { scale: 0.95, opacity: 0 } as const; const tooltipContentWebStyle = { width: 'max-content' } as const; -const useHoverTooltip = () => { - const [isHovered, setIsHovered] = useState(false); - const showTooltipRef = useRef(isHovered); - showTooltipRef.current = isHovered; - const closeTooltipTimer = useRef | null>(null); - const showTooltipTimer = useRef | null>(null); - const handleHoverIn = useCallback(() => { - if (showTooltipRef.current) { - if (closeTooltipTimer.current) { - clearTimeout(closeTooltipTimer.current); - } - } else { - showTooltipTimer.current = setTimeout(() => { - setIsHovered(true); - }, 250); - } - }, []); - const dismissTooltip = useCallback(() => { - setIsHovered(false); - }, []); - const handleHoverOut = useCallback(() => { - if (showTooltipRef.current) { - closeTooltipTimer.current = setTimeout(() => { - dismissTooltip(); - }, 300); - } else if (showTooltipTimer.current) { - clearTimeout(showTooltipTimer.current); - } - }, [dismissTooltip]); - return { - setIsHovered, - isHovered, - onContentHoverIn: handleHoverIn, - onContentHoverOut: handleHoverOut, - }; -}; - const transformOriginMap: Record< NonNullable, string @@ -97,12 +55,19 @@ export function Tooltip({ [transformOrigin], ); - const [isShow, setIsShow] = useState(false); - const [forceClose, setForceClose] = useState(false); - const [isDisabled, setIsDisabled] = useState(false); - - const { isHovered, setIsHovered, onContentHoverIn, onContentHoverOut } = - useHoverTooltip(); + const { + isOpen, + setIsShow, + setIsDisabled, + handleOpenChange, + handleTriggerPointerDown, + handleTriggerMouseEnter, + handleTriggerMouseLeave, + handleContentMouseEnter, + handleContentMouseLeave, + closeTooltip, + openTooltip, + } = useTooltipOpenState({ hovering }); const renderTooltipContent = useMemo(() => { if (typeof renderContent === 'string') { @@ -118,59 +83,15 @@ export function Tooltip({ } return renderContent; - }, [renderContent, shortcutKey]); - - const isOpen = useMemo(() => { - if (forceClose) { - return false; - } - if (hovering) { - return isHovered; - } - return isDisabled ? false : isShow; - }, [forceClose, hovering, isDisabled, isShow, isHovered]); - - const handleHoverIn = useCallback(() => { - if (hovering) { - onContentHoverIn(); - } - }, [hovering, onContentHoverIn]); - - const handleHoverOut = useCallback(() => { - if (hovering) { - onContentHoverOut(); - } - }, [hovering, onContentHoverOut]); - - const closeTooltip = useCallback(() => { - return new Promise((resolve) => { - setForceClose(true); - setIsShow(false); - setIsHovered(false); - setTimeout(() => { - resolve(); - }, 150); - setTimeout(() => { - setForceClose(false); - }, 200); - }); - }, [setIsHovered, setIsShow, setForceClose]); + }, [renderContent, setIsDisabled, setIsShow, shortcutKey]); useImperativeHandle( ref, () => ({ closeTooltip, - openTooltip: () => { - return new Promise((resolve) => { - setIsShow(true); - setIsHovered(true); - setTimeout(() => { - resolve(); - }, 50); - }); - }, + openTooltip, }), - [closeTooltip, setIsHovered], + [closeTooltip, openTooltip], ); const contextValue = useMemo( @@ -188,7 +109,7 @@ export function Tooltip({ delay={0} offset={6} open={isOpen} - onOpenChange={setIsShow} + onOpenChange={handleOpenChange} allowFlip placement={placement} {...props} @@ -198,8 +119,13 @@ export function Tooltip({ {renderTrigger} @@ -222,8 +148,8 @@ export function Tooltip({ exitStyle={tooltipExitStyle} transition="quick" animateOnly={ANIMATE_ONLY_OPACITY_TRANSFORM} - onMouseEnter={handleHoverIn} - onMouseLeave={handleHoverOut} + onMouseEnter={handleContentMouseEnter} + onMouseLeave={handleContentMouseLeave} > {renderTooltipContent} @@ -235,4 +161,5 @@ export function Tooltip({ Tooltip.Text = TooltipText; export * from './context'; +export { closeAllTooltips } from './tooltipRegistry'; export * from './type'; diff --git a/packages/components/src/actions/Tooltip/tooltipRegistry.test.ts b/packages/components/src/actions/Tooltip/tooltipRegistry.test.ts new file mode 100644 index 000000000000..0f92118ce0d0 --- /dev/null +++ b/packages/components/src/actions/Tooltip/tooltipRegistry.test.ts @@ -0,0 +1,38 @@ +import { closeAllTooltips, registerOpenTooltip } from './tooltipRegistry'; + +describe('tooltipRegistry', () => { + it('closeAllTooltips invokes every registered closer once', () => { + const closeA = jest.fn(); + const closeB = jest.fn(); + const disposeA = registerOpenTooltip(closeA); + const disposeB = registerOpenTooltip(closeB); + + closeAllTooltips(); + + expect(closeA).toHaveBeenCalledTimes(1); + expect(closeB).toHaveBeenCalledTimes(1); + disposeA(); + disposeB(); + }); + + it('a disposed closer is no longer invoked', () => { + const close = jest.fn(); + const dispose = registerOpenTooltip(close); + dispose(); + + closeAllTooltips(); + + expect(close).not.toHaveBeenCalled(); + }); + + it('a closer that disposes itself while closing does not skip the others', () => { + const closeB = jest.fn(); + const disposeA = registerOpenTooltip(() => disposeA()); + const disposeB = registerOpenTooltip(closeB); + + closeAllTooltips(); + + expect(closeB).toHaveBeenCalledTimes(1); + disposeB(); + }); +}); diff --git a/packages/components/src/actions/Tooltip/tooltipRegistry.ts b/packages/components/src/actions/Tooltip/tooltipRegistry.ts new file mode 100644 index 000000000000..fef2a367f229 --- /dev/null +++ b/packages/components/src/actions/Tooltip/tooltipRegistry.ts @@ -0,0 +1,16 @@ +// Registry of tooltips that are currently open. Modal navigation calls +// closeAllTooltips() because pushing a modal does not move the pointer, so no +// mouseleave fires and a hover tooltip would otherwise linger above the modal. +const openTooltipClosers = new Set<() => void>(); + +export function registerOpenTooltip(close: () => void): () => void { + openTooltipClosers.add(close); + return () => { + openTooltipClosers.delete(close); + }; +} + +export function closeAllTooltips() { + // Snapshot first: a closer may unregister itself synchronously. + Array.from(openTooltipClosers).forEach((close) => close()); +} diff --git a/packages/components/src/actions/Tooltip/useTooltipOpenState.test.tsx b/packages/components/src/actions/Tooltip/useTooltipOpenState.test.tsx new file mode 100644 index 000000000000..fc9493d925e0 --- /dev/null +++ b/packages/components/src/actions/Tooltip/useTooltipOpenState.test.tsx @@ -0,0 +1,138 @@ +/** + * @jest-environment jsdom + */ + +import { act, renderHook } from '@testing-library/react'; + +import { closeAllTooltips } from './tooltipRegistry'; +import { useTooltipOpenState } from './useTooltipOpenState'; + +const mousePress = { pointerType: 'mouse' }; +const touchPress = { pointerType: 'touch' }; + +describe('useTooltipOpenState', () => { + it('opens and closes when the floating controller reports open changes', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + + act(() => result.current.handleOpenChange(true)); + expect(result.current.isOpen).toBe(true); + + act(() => result.current.handleOpenChange(false)); + expect(result.current.isOpen).toBe(false); + }); + + it('a mouse press on the trigger closes the tooltip', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + + act(() => result.current.handleTriggerPointerDown(mousePress)); + + expect(result.current.isOpen).toBe(false); + }); + + it('after a mouse press the tooltip stays closed until the pointer leaves the trigger', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + act(() => result.current.handleTriggerPointerDown(mousePress)); + + act(() => result.current.handleOpenChange(true)); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.handleTriggerMouseLeave()); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.handleOpenChange(true)); + expect(result.current.isOpen).toBe(true); + }); + + it('a touch press does not close the tooltip', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + + act(() => result.current.handleTriggerPointerDown(touchPress)); + + expect(result.current.isOpen).toBe(true); + }); + + it('closeAllTooltips closes an open tooltip', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + + act(() => closeAllTooltips()); + + expect(result.current.isOpen).toBe(false); + }); + + it('a tooltip closed by closeAllTooltips can reopen on the next hover', () => { + const { result } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + act(() => closeAllTooltips()); + + act(() => result.current.handleOpenChange(true)); + + expect(result.current.isOpen).toBe(true); + }); + + it('an unmounted tooltip is not closed by closeAllTooltips', () => { + const { result, unmount } = renderHook(() => useTooltipOpenState({})); + act(() => result.current.handleOpenChange(true)); + unmount(); + + expect(() => closeAllTooltips()).not.toThrow(); + }); + + describe('hovering mode', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('a mouse press closes a hover-opened tooltip and blocks re-hover until leave', () => { + const { result } = renderHook(() => + useTooltipOpenState({ hovering: true }), + ); + act(() => result.current.handleTriggerMouseEnter()); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(result.current.isOpen).toBe(true); + + act(() => result.current.handleTriggerPointerDown(mousePress)); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.handleTriggerMouseEnter()); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.handleTriggerMouseLeave()); + expect(result.current.isOpen).toBe(false); + act(() => { + jest.advanceTimersByTime(300); + }); + act(() => result.current.handleTriggerMouseEnter()); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(result.current.isOpen).toBe(true); + }); + + it('closeAllTooltips closes a hover-opened tooltip', () => { + const { result } = renderHook(() => + useTooltipOpenState({ hovering: true }), + ); + act(() => result.current.handleTriggerMouseEnter()); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(result.current.isOpen).toBe(true); + + act(() => closeAllTooltips()); + + expect(result.current.isOpen).toBe(false); + }); + }); +}); diff --git a/packages/components/src/actions/Tooltip/useTooltipOpenState.ts b/packages/components/src/actions/Tooltip/useTooltipOpenState.ts new file mode 100644 index 000000000000..082d86f532b0 --- /dev/null +++ b/packages/components/src/actions/Tooltip/useTooltipOpenState.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { registerOpenTooltip } from './tooltipRegistry'; + +const HOVER_OPEN_DELAY_MS = 250; +const HOVER_CLOSE_DELAY_MS = 300; +const FORCE_CLOSE_SETTLE_MS = 150; +const FORCE_CLOSE_RELEASE_MS = 200; +const OPEN_SETTLE_MS = 50; + +export interface ITooltipPointerDownEvent { + pointerType?: string; +} + +// Hover state for interactive (`hovering`) tooltips: the content stays open +// while the pointer is over either the trigger or the content. +const useHoverTooltip = () => { + const [isHovered, setIsHovered] = useState(false); + const showTooltipRef = useRef(isHovered); + showTooltipRef.current = isHovered; + const closeTooltipTimer = useRef | null>(null); + const showTooltipTimer = useRef | null>(null); + const handleHoverIn = useCallback(() => { + if (showTooltipRef.current) { + if (closeTooltipTimer.current) { + clearTimeout(closeTooltipTimer.current); + } + } else { + showTooltipTimer.current = setTimeout(() => { + setIsHovered(true); + }, HOVER_OPEN_DELAY_MS); + } + }, []); + const dismissTooltip = useCallback(() => { + setIsHovered(false); + }, []); + const handleHoverOut = useCallback(() => { + if (showTooltipRef.current) { + closeTooltipTimer.current = setTimeout(() => { + dismissTooltip(); + }, HOVER_CLOSE_DELAY_MS); + } else if (showTooltipTimer.current) { + clearTimeout(showTooltipTimer.current); + } + }, [dismissTooltip]); + return { + setIsHovered, + isHovered, + onContentHoverIn: handleHoverIn, + onContentHoverOut: handleHoverOut, + }; +}; + +export function useTooltipOpenState({ hovering }: { hovering?: boolean }) { + const [isShow, setIsShow] = useState(false); + const [forceClose, setForceClose] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + // A mouse/pen press on the trigger closes the tooltip and keeps it closed + // until the pointer leaves the trigger, so activating the trigger (opening a + // modal, copying, ...) never leaves a tooltip hanging over the result. + const [isPressLatched, setIsPressLatched] = useState(false); + const isPressLatchedRef = useRef(isPressLatched); + isPressLatchedRef.current = isPressLatched; + + const { isHovered, setIsHovered, onContentHoverIn, onContentHoverOut } = + useHoverTooltip(); + + const isOpen = useMemo(() => { + if (forceClose || isPressLatched) { + return false; + } + if (hovering) { + return isHovered; + } + return isDisabled ? false : isShow; + }, [forceClose, isPressLatched, hovering, isDisabled, isShow, isHovered]); + + const handleOpenChange = useCallback((open: boolean) => { + setIsShow(open); + }, []); + + const handleTriggerPointerDown = useCallback( + (event: ITooltipPointerDownEvent) => { + // Touch has no hover: a tap is the only way to reveal the tooltip there. + if (event.pointerType === 'touch') { + return; + } + setIsPressLatched(true); + setIsShow(false); + setIsHovered(false); + }, + [setIsHovered], + ); + + const handleTriggerMouseEnter = useCallback(() => { + if (hovering) { + onContentHoverIn(); + } + }, [hovering, onContentHoverIn]); + + const handleTriggerMouseLeave = useCallback(() => { + if (isPressLatchedRef.current) { + setIsPressLatched(false); + // Drop open requests that arrived while latched; the tooltip must only + // reopen on a fresh hover. + setIsShow(false); + setIsHovered(false); + } + if (hovering) { + onContentHoverOut(); + } + }, [hovering, onContentHoverOut, setIsHovered]); + + const handleContentMouseEnter = useCallback(() => { + if (hovering) { + onContentHoverIn(); + } + }, [hovering, onContentHoverIn]); + + const handleContentMouseLeave = useCallback(() => { + if (hovering) { + onContentHoverOut(); + } + }, [hovering, onContentHoverOut]); + + const closeTooltip = useCallback(() => { + return new Promise((resolve) => { + setForceClose(true); + setIsShow(false); + setIsHovered(false); + setTimeout(() => { + resolve(); + }, FORCE_CLOSE_SETTLE_MS); + setTimeout(() => { + setForceClose(false); + }, FORCE_CLOSE_RELEASE_MS); + }); + }, [setIsHovered]); + + const openTooltip = useCallback(() => { + return new Promise((resolve) => { + setIsShow(true); + setIsHovered(true); + setTimeout(() => { + resolve(); + }, OPEN_SETTLE_MS); + }); + }, [setIsHovered]); + + const closeFromRegistry = useCallback(() => { + setIsShow(false); + setIsHovered(false); + }, [setIsHovered]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + return registerOpenTooltip(closeFromRegistry); + }, [isOpen, closeFromRegistry]); + + return { + isOpen, + setIsShow, + setIsDisabled, + handleOpenChange, + handleTriggerPointerDown, + handleTriggerMouseEnter, + handleTriggerMouseLeave, + handleContentMouseEnter, + handleContentMouseLeave, + closeTooltip, + openTooltip, + }; +} diff --git a/packages/components/src/actions/index.web-only.ts b/packages/components/src/actions/index.web-only.ts index 0d46fd06baf8..c64133f6238d 100644 --- a/packages/components/src/actions/index.web-only.ts +++ b/packages/components/src/actions/index.web-only.ts @@ -11,5 +11,6 @@ export * from './Shortcut'; export * from './Toast'; export { LazyTooltip as Tooltip } from './LazyTooltip'; export { useTooltipContext } from './Tooltip/context'; +export { closeAllTooltips } from './Tooltip/tooltipRegistry'; export * from './Trigger'; export * from './Pagination'; diff --git a/packages/kit/src/hooks/useAppNavigation.closeTooltips.test.tsx b/packages/kit/src/hooks/useAppNavigation.closeTooltips.test.tsx new file mode 100644 index 000000000000..d05dfdf0579d --- /dev/null +++ b/packages/kit/src/hooks/useAppNavigation.closeTooltips.test.tsx @@ -0,0 +1,89 @@ +/** + * @jest-environment jsdom + */ + +import { act, renderHook } from '@testing-library/react'; + +import { EModalRoutes } from '@onekeyhq/shared/src/routes'; + +const callOrder: string[] = []; +const mockPush = jest.fn((...args: unknown[]) => { + callOrder.push('push'); + return args; +}); +const mockCloseAllTooltips = jest.fn(() => { + callOrder.push('closeAllTooltips'); +}); + +jest.mock('@react-navigation/core', () => ({ + useNavigation: () => ({ + push: (...args: unknown[]) => mockPush(...args), + getParent: () => undefined, + getState: () => ({ index: 0, routes: [] }), + }), +})); + +jest.mock('@onekeyhq/components', () => ({ + closeAllTooltips: () => mockCloseAllTooltips(), + useSplitMainView: () => false, + Page: { + Header: { + usePageHeaderReloadOptions: () => ({ + reload: (options: unknown) => options, + }), + }, + }, + rootNavigationRef: { current: null }, + tabletMainViewNavigationRef: { current: null }, + popToMainRoute: jest.fn(), + popToTabRootScreen: jest.fn(), + resetAboveMainRoute: jest.fn(), + switchTab: jest.fn(), + switchTabAsync: jest.fn(), +})); + +// eslint-disable-next-line import/first +import useAppNavigation from './useAppNavigation'; + +describe('useAppNavigation closes open tooltips when a modal is pushed', () => { + beforeEach(() => { + jest.useFakeTimers(); + callOrder.length = 0; + mockPush.mockClear(); + mockCloseAllTooltips.mockClear(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + it('pushModal closes tooltips before pushing the modal route', () => { + const { result } = renderHook(() => useAppNavigation()); + + act(() => { + result.current.pushModal(EModalRoutes.WalletAddress, { + screen: 'WalletAddress' as never, + }); + }); + + expect(mockCloseAllTooltips).toHaveBeenCalledTimes(1); + expect(mockPush).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['closeAllTooltips', 'push']); + }); + + it('pushFullModal closes tooltips before pushing the modal route', () => { + const { result } = renderHook(() => useAppNavigation()); + + act(() => { + result.current.pushFullModal(EModalRoutes.WalletAddress, { + screen: 'WalletAddress' as never, + }); + }); + + expect(mockCloseAllTooltips).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['closeAllTooltips', 'push']); + }); +}); diff --git a/packages/kit/src/hooks/useAppNavigation.ts b/packages/kit/src/hooks/useAppNavigation.ts index ae5801fd0a81..1cbaa7f54fe8 100644 --- a/packages/kit/src/hooks/useAppNavigation.ts +++ b/packages/kit/src/hooks/useAppNavigation.ts @@ -4,6 +4,7 @@ import { useNavigation } from '@react-navigation/core'; import { Page, + closeAllTooltips, popToMainRoute, popToTabRootScreen, resetAboveMainRoute, @@ -256,6 +257,9 @@ function useAppNavigation< params?: IModalParamList[T][keyof IModalParamList[T]]; }, ) => { + // The pointer does not move when a modal appears, so a hover tooltip + // would otherwise stay open above it until the next mouse move. + closeAllTooltips(); const navigationInstance = navigationRef.current; const target: IPendingModalTarget = { modalType, From 777fc03d650834508f1bf92a066fc1be596cd4c0 Mon Sep 17 00:00:00 2001 From: weatherstar Date: Mon, 7 Sep 2026 15:29:41 +0800 Subject: [PATCH 2/9] test: lock background RPC error rehydration against getter-only constructorName (OK-61417) --- .../setupBackgroundThreadRPCHandler.test.ts | 42 +++++++++ .../setupMainThreadBackgroundRunner.test.ts | 94 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.test.ts b/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.test.ts index a026539e25ce..c0fb1f51a739 100644 --- a/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.test.ts +++ b/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.test.ts @@ -1,5 +1,9 @@ +import { HardwareErrorCode } from '@onekeyfe/hd-shared'; + import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { IncorrectPinError } from '@onekeyhq/shared/src/errors/errors/appErrors'; +import { DeviceNotFound } from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; +import { EOneKeyErrorClassNames } from '@onekeyhq/shared/src/errors/types/errorTypes'; import { ETranslations } from '@onekeyhq/shared/src/locale'; const mockSharedRPCWrite = jest.fn(); @@ -166,6 +170,44 @@ describe('background thread RPC handler', () => { expect(response).not.toHaveProperty('error.stack'); }); + it('serializes a hardware error without the getter-only constructorName (OK-61417)', async () => { + const { setBackgroundThreadRequestExecutor } = + await import('./setupBackgroundThreadRPCHandler'); + const hardwarePayload = { + code: HardwareErrorCode.DeviceNotFound, + error: 'Device not found', + connectId: 'ble-connect-id', + deviceId: 'device-id', + }; + const error = new DeviceNotFound({ + payload: hardwarePayload, + silentMode: true, + }); + setBackgroundThreadRequestExecutor(() => Promise.reject(error)); + mockSharedRPCWrite.mockClear(); + + dispatchServiceRequest('hardware'); + await flushRequest(); + + const response = getResponse('hardware'); + expect(response).toMatchObject({ + ok: false, + error: { + className: EOneKeyErrorClassNames.DeviceNotFound, + $isHardwareError: true, + code: HardwareErrorCode.DeviceNotFound, + key: ETranslations.hardware_device_not_find_error, + payload: hardwarePayload, + reconnect: true, + }, + }); + // constructorName is a getter-only accessor on the main-runtime error + // classes; serializing it invited the rehydration TypeError that hung + // the create-address flow. + expect(response).not.toHaveProperty('error.constructorName'); + expect(response).not.toHaveProperty('error.stack'); + }); + it('serializes a nullish rejection without falling back', async () => { const { setBackgroundThreadRequestExecutor } = await import('./setupBackgroundThreadRPCHandler'); diff --git a/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.test.ts b/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.test.ts index b883b2f4c575..c3ca06758bd9 100644 --- a/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.test.ts +++ b/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.test.ts @@ -55,6 +55,13 @@ jest.mock('@onekeyhq/shared/src/errors', () => ({ OneKeyLocalError: class OneKeyLocalError extends Error { key = 'onekey_error'; + // Mirrors OneKeyError#constructorName: a getter-only accessor. Assigning + // to it in strict mode throws, which is the trap that used to skip + // pendingCall.reject and hang the caller (OK-61451, OK-61417). + get constructorName() { + return this.constructor.name; + } + constructor(message: string) { super(message); if (mockRejectErrorKeyAssignment) { @@ -197,6 +204,93 @@ describe('main thread background runner', () => { }); }); + it('rejects a hardware error response without touching getter-only constructorName (OK-61417)', async () => { + await import('./setupMainThreadBackgroundRunner'); + + const transport = ( + globalThis as typeof globalThis & { + __onekeyNativeBackgroundThreadTransport?: { + callServiceRequest: ( + request: { + type: 'service-call'; + method: string; + params: unknown[]; + sync: boolean; + }, + localFallback: () => Promise, + ) => Promise; + }; + } + ).__onekeyNativeBackgroundThreadTransport; + + const requestPromise = transport!.callServiceRequest( + { + type: 'service-call', + method: 'serviceAccount.addHDOrHWAccounts', + params: [{ walletId: 'hw-1', networkId: 'btc--0' }], + sync: false, + }, + () => Promise.resolve(undefined), + ); + const requestCalls = mockSharedRPCWrite.mock.calls.filter( + ([key]) => typeof key === 'string' && key.startsWith('onekey:bg:req:'), + ); + const requestCall = requestCalls[requestCalls.length - 1]; + const callId = (requestCall?.[0] as string).slice('onekey:bg:req:'.length); + const hardwarePayload = { + code: 105, + error: 'Device not found', + connectId: 'ble-connect-id', + deviceId: 'device-id', + }; + + // Shape of a hardware failure crossing the bridge after the BLE retries + // give up on a powered-off device. Legacy background bundles still put + // the constructorName getter value on the wire. + expect(() => + mockInboundMessageHandler?.( + `onekey:bg:res:${callId}`, + JSON.stringify({ + ok: false, + error: { + name: 'DeviceNotFound', + message: 'Device not found', + className: 'DeviceNotFound', + $isHardwareError: true, + code: 105, + key: 'hardware.device_not_find_error', + autoToast: true, + reconnect: true, + payload: hardwarePayload, + constructorName: 'DeviceNotFound', + }, + }), + ), + ).not.toThrow(); + + // A resolved call yields `undefined` here and fails the shape check below. + const error = (await requestPromise.catch( + (rejection: unknown) => rejection, + )) as Error & Record; + expect(error).toMatchObject({ + name: 'DeviceNotFound', + message: 'Device not found', + className: 'DeviceNotFound', + $isHardwareError: true, + code: 105, + key: 'hardware.device_not_find_error', + autoToast: true, + reconnect: true, + payload: hardwarePayload, + }); + // The getter stays on the prototype; the wire value must never be + // written onto the instance. + expect( + Object.getOwnPropertyDescriptor(error, 'constructorName'), + ).toBeUndefined(); + expect(error.constructorName).toBe('OneKeyLocalError'); + }); + it('continues rehydrating after an error metadata field fails', async () => { await import('./setupMainThreadBackgroundRunner'); mockRejectErrorKeyAssignment = true; From d8b245e41ee27b1321caa970ed0a9645a5f676bd Mon Sep 17 00:00:00 2001 From: weatherstar Date: Mon, 7 Sep 2026 16:21:16 +0800 Subject: [PATCH 3/9] fix: dismiss keyboard on blank tap in custom token page (OK-61527) --- .../pages/AddCustomTokenModal.test.tsx | 161 ++++++++++++++++++ .../AssetList/pages/AddCustomTokenModal.tsx | 13 +- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/kit/src/views/AssetList/pages/AddCustomTokenModal.test.tsx diff --git a/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.test.tsx b/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.test.tsx new file mode 100644 index 000000000000..1d5f85fe072b --- /dev/null +++ b/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.test.tsx @@ -0,0 +1,161 @@ +/** @jest-environment jsdom */ + +import type { ReactNode } from 'react'; + +import { render } from '@testing-library/react'; + +import AddCustomTokenModal from './AddCustomTokenModal'; + +const mockPageProps = jest.fn(); + +jest.mock('@react-navigation/core', () => ({ + useRoute: () => ({ + params: { + walletId: 'hd-1', + networkId: 'evm--1', + indexedAccountId: 'hd-1--0', + accountId: 'hd-1--m/44h/60h/0h/0/0', + isOthersWallet: false, + deriveType: 'default', + }, + }), +})); + +jest.mock('react-intl', () => ({ + useIntl: () => ({ + formatMessage: ({ id }: { id: string }) => id, + }), +})); + +jest.mock('@onekeyhq/components', () => { + const React = jest.requireActual('react'); + + const Container = ({ children }: { children?: ReactNode }) => + React.createElement('div', null, children); + const Empty = () => null; + const Page = Object.assign( + (props: { children?: ReactNode }) => { + mockPageProps(props); + return React.createElement('div', null, props.children); + }, + { + Header: Empty, + Body: Container, + Footer: Container, + }, + ); + const Form = Object.assign(Container, { Field: Container }); + + return { + Button: Container, + Form, + Icon: Empty, + Input: Empty, + Page, + SizableText: Container, + Stack: Container, + Toast: { error: jest.fn(), success: jest.fn() }, + XStack: Container, + }; +}); + +jest.mock('@onekeyhq/kit/src/components/AccountSelector', () => { + const React = jest.requireActual('react'); + return { + AccountSelectorProviderMirror: ({ children }: { children?: ReactNode }) => + React.createElement(React.Fragment, null, children), + ControlledNetworkSelectorTrigger: () => null, + }; +}); + +jest.mock( + '@onekeyhq/kit/src/components/AccountSelector/AccountSelectorCreateAddressButton', + () => ({ + AccountSelectorCreateAddressButton: () => null, + }), +); + +jest.mock('@onekeyhq/kit/src/hooks/useDappApproveAction', () => ({ + __esModule: true, + default: () => ({ resolve: jest.fn(), reject: jest.fn() }), +})); + +jest.mock('@onekeyhq/kit/src/hooks/useDappQuery', () => ({ + __esModule: true, + default: () => ({}), +})); + +jest.mock('@onekeyhq/shared/src/logger/logger', () => ({ + defaultLogger: { + account: { wallet: { addCustomToken: jest.fn() } }, + }, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: {}, + appEventBus: { emit: jest.fn() }, +})); + +jest.mock('../../../background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: {}, +})); + +jest.mock('../../../components/NetworkAvatar/NetworkAvatar', () => ({ + NetworkAvatar: () => null, +})); + +jest.mock('../../../hooks/usePromiseResult', () => ({ + usePromiseResult: ( + _fn: unknown, + _deps: unknown, + options?: { initResult?: unknown }, + ) => ({ + result: options?.initResult, + run: jest.fn(), + }), +})); + +jest.mock('../../DAppConnection/pages/DappOpenModalPage', () => ({ + useDappCloseHandler: () => jest.fn(), +})); + +jest.mock('../hooks/useAddToken', () => ({ + useAddTokenForm: () => ({ + form: {}, + isEmptyContract: false, + setIsEmptyContractState: jest.fn(), + selectedNetworkIdValue: 'evm--1', + contractAddressValue: '', + symbolValue: '', + decimalsValue: '', + isSymbolEditable: false, + setIsSymbolEditable: jest.fn(), + }), + useCheckAccountExist: () => ({ + hasExistAccount: true, + runCheckAccountExist: jest.fn(), + checkAccountIsExist: jest.fn(), + }), + useAddToken: () => ({ + availableNetworks: undefined, + searchedTokenRef: { current: undefined }, + isSearching: false, + }), +})); + +describe('AddCustomTokenModal', () => { + it('scrolls the form so a blank tap or drag dismisses the keyboard (OK-61527)', () => { + render(); + + expect(mockPageProps).toHaveBeenCalledWith( + expect.objectContaining({ + scrollEnabled: true, + scrollProps: expect.objectContaining({ + keyboardDismissMode: 'on-drag', + keyboardShouldPersistTaps: 'handled', + }), + }), + ); + }); +}); diff --git a/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.tsx b/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.tsx index 1c7c4f37e707..793734821679 100644 --- a/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.tsx +++ b/packages/kit/src/views/AssetList/pages/AddCustomTokenModal.tsx @@ -4,7 +4,7 @@ import { useRoute } from '@react-navigation/core'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; -import type { IButtonProps } from '@onekeyhq/components'; +import type { IButtonProps, IPageProps } from '@onekeyhq/components'; import { Button, Form, @@ -66,6 +66,15 @@ function normalizeAddress(address: string) { return address.toLowerCase(); } +// Page.Body alone owns no touch responder, so once an Input is focused nothing +// on this page can release the keyboard and it keeps covering the lower fields. +// Routing the form through the page scroll view gives blank-area taps and drags +// the standard keyboard-dismiss behavior and keeps the focused field visible. +const PAGE_SCROLL_PROPS: IPageProps['scrollProps'] = { + keyboardDismissMode: 'on-drag', + keyboardShouldPersistTaps: 'handled', +}; + function CreateAddressButton(props: IButtonProps) { const intl = useIntl(); return ( @@ -471,7 +480,7 @@ function AddCustomTokenModal() { const handleOnClose = useDappCloseHandler(dappApprove); return ( - + Date: Mon, 7 Sep 2026 16:57:59 +0800 Subject: [PATCH 4/9] fix: keep home tabs and banner images stable on cold start (OK-61505) --- .../src/utils/coldStartImagePreload.test.ts | 68 ++++++++ .../kit/src/utils/coldStartImagePreload.ts | 46 ++++- .../components/WalletBanner/WalletBanner.tsx | 9 +- .../useHomeWalletTabSupport.hook.test.tsx | 157 ++++++++++++++++++ .../hooks/useHomeWalletTabSupport.test.ts | 27 +++ .../Home/hooks/useHomeWalletTabSupport.ts | 44 +++-- .../src/utils/homeWalletTabSupportUtils.ts | 19 +++ packages/shared/src/utils/swrCacheUtils.ts | 6 + 8 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 packages/kit/src/utils/coldStartImagePreload.test.ts create mode 100644 packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.hook.test.tsx diff --git a/packages/kit/src/utils/coldStartImagePreload.test.ts b/packages/kit/src/utils/coldStartImagePreload.test.ts new file mode 100644 index 000000000000..c35857913d65 --- /dev/null +++ b/packages/kit/src/utils/coldStartImagePreload.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable import/first */ +import { CONTEXT_ATOM_COLD_START_CACHE_KEYS } from '@onekeyhq/shared/src/consts/jotaiConsts'; + +const mockPreloadImages: jest.Mock, unknown[]> = jest.fn(); + +// jest maps every '@onekeyhq/components*' path to __mocks__/componentsMock.ts, so +// this factory must also keep the `s` scale helper that tokenSize.ts reads. +jest.mock('@onekeyhq/components/src/primitives/Image/preload', () => ({ + s: (size: number) => size, + preloadImages: (...args: unknown[]) => mockPreloadImages(...args), +})); + +import { + WALLET_BANNER_IMAGE_SIZE, + getColdStartImageUrisFromSnapshot, + prewarmColdStartImagesFromSnapshot, +} from './coldStartImagePreload'; + +const bannerUri = 'https://uni.onekey-asset.com/banner/smci.png'; +const tokenUri = 'https://uni.onekey-asset.com/icons/eth.png'; + +const snapshot = { + [`store-a::${CONTEXT_ATOM_COLD_START_CACHE_KEYS.walletTopBannersAtom}`]: { + banners: [ + { id: 'smci', src: bannerUri }, + { id: 'local-referral', src: '' }, + { id: 'inline', src: 'data:image/png;base64,AAAA' }, + { id: 'dup', src: bannerUri }, + ], + }, + [`store-a::${CONTEXT_ATOM_COLD_START_CACHE_KEYS.tokenListSlimColdCacheAtom}`]: + { + compactMeta: { + eth: { logoURI: tokenUri }, + }, + }, +}; + +describe('coldStartImagePreload wallet banner images (OK-61505)', () => { + beforeEach(() => { + mockPreloadImages.mockReset(); + mockPreloadImages.mockResolvedValue(true); + }); + + it('collects remote banner images ahead of token logos, sized to the banner card', () => { + expect(getColdStartImageUrisFromSnapshot(snapshot)).toEqual([ + { uri: bannerUri, resizeWidth: WALLET_BANNER_IMAGE_SIZE }, + tokenUri, + ]); + }); + + it('prewarms banner images with the banner size instead of the token default', async () => { + await prewarmColdStartImagesFromSnapshot({ snapshot }); + + expect(mockPreloadImages).toHaveBeenCalledTimes(1); + const sources = mockPreloadImages.mock.calls[0][0] as Array<{ + uri: string; + resizeWidth?: number; + }>; + expect(sources).toEqual([ + expect.objectContaining({ + uri: bannerUri, + resizeWidth: WALLET_BANNER_IMAGE_SIZE, + }), + expect.objectContaining({ uri: tokenUri, resizeWidth: 32 }), + ]); + }); +}); diff --git a/packages/kit/src/utils/coldStartImagePreload.ts b/packages/kit/src/utils/coldStartImagePreload.ts index c5db604d1759..61d42ea37159 100644 --- a/packages/kit/src/utils/coldStartImagePreload.ts +++ b/packages/kit/src/utils/coldStartImagePreload.ts @@ -43,6 +43,11 @@ type ITokenSelectorImageItem = { const REMOTE_IMAGE_URI_RE = /^https?:\/\//i; const COLD_START_IMAGE_PRELOAD_LIMIT = 96; +// Logical size of the image inside a home wallet banner card. WalletBanner +// renders with this exact size, so the cold-start prewarm below produces the +// same resize URL + decode thumbnail as the first paint (cache-key match). +export const WALLET_BANNER_IMAGE_SIZE = 56; +const WALLET_BANNER_IMAGE_LIMIT = 8; const WALLET_TOKEN_OWNER_LIMIT = 2; const WALLET_TOKEN_LIMIT_PER_OWNER = 24; const SWAP_POSITION_OWNER_LIMIT = 3; @@ -181,6 +186,41 @@ function getUpdatedAt(value: unknown) { : Number.MIN_SAFE_INTEGER; } +// Wallet banner cards render their text from the cold-start snapshot on the +// first frame; without a warm image cache the 56pt image shows a skeleton on +// every launch (OK-61505). Prewarm them at the banner size, ahead of the +// token logos, so the first paint hits the memory cache. +function collectWalletBannerImageItems({ + items, + snapshot, +}: { + items: IImagePreloadItem[]; + snapshot: IColdStartSnapshot; +}) { + const seen = new Set(); + for (const value of getSnapshotValuesByColdStartKey({ + snapshot, + coldStartCacheKey: CONTEXT_ATOM_COLD_START_CACHE_KEYS.walletTopBannersAtom, + })) { + const banners = + isRecord(value) && Array.isArray(value.banners) ? value.banners : []; + for (const banner of banners) { + if (seen.size >= WALLET_BANNER_IMAGE_LIMIT) { + return; + } + const uri = isRecord(banner) ? banner.src : undefined; + if ( + typeof uri === 'string' && + REMOTE_IMAGE_URI_RE.test(uri) && + !seen.has(uri) + ) { + seen.add(uri); + items.push({ uri, resizeWidth: WALLET_BANNER_IMAGE_SIZE }); + } + } + } +} + function collectWalletTokenImageUris({ uris, snapshot, @@ -337,17 +377,19 @@ function collectPerpsImageUris({ export function getColdStartImageUrisFromSnapshot( snapshot = getColdStartSnapshot(), limit = COLD_START_IMAGE_PRELOAD_LIMIT, -) { +): IImagePreloadInput[] { + const bannerItems: IImagePreloadItem[] = []; const uris = new Set(); if (!snapshot) { return []; } + collectWalletBannerImageItems({ items: bannerItems, snapshot }); collectWalletTokenImageUris({ uris, snapshot }); collectSwapImageUris({ uris, snapshot }); collectPerpsImageUris({ uris, snapshot }); - return [...uris].slice(0, limit); + return [...bannerItems, ...uris].slice(0, limit); } export function getPerpsTokenSelectorImageUrisFromItems({ diff --git a/packages/kit/src/views/Home/components/WalletBanner/WalletBanner.tsx b/packages/kit/src/views/Home/components/WalletBanner/WalletBanner.tsx index 0c97c2733f2e..4e3704c61cb1 100644 --- a/packages/kit/src/views/Home/components/WalletBanner/WalletBanner.tsx +++ b/packages/kit/src/views/Home/components/WalletBanner/WalletBanner.tsx @@ -39,6 +39,7 @@ import { } from '@onekeyhq/kit/src/states/jotai/contexts/accountOverview'; import { useActiveAccount } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; import { shouldBlockBotWalletReceive } from '@onekeyhq/kit/src/utils/botWalletStatusUtils'; +import { WALLET_BANNER_IMAGE_SIZE } from '@onekeyhq/kit/src/utils/coldStartImagePreload'; import { HYPERLIQUID_REFERRAL_CODE, PERPS_NETWORK_ID, @@ -117,8 +118,12 @@ function BannerItem({ gap="$3" > {item.src ? ( - - + + ) : null} {/* The decorative icon is bottom-anchored (right/bottom "$4", 24pt), so diff --git a/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.hook.test.tsx b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.hook.test.tsx new file mode 100644 index 000000000000..7b86583d1e89 --- /dev/null +++ b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.hook.test.tsx @@ -0,0 +1,157 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first */ + +// Polyfill requestIdleCallback/cancelIdleCallback for non-native environments +if (typeof globalThis.requestIdleCallback === 'undefined') { + (globalThis as any).requestIdleCallback = (cb: () => void) => + setTimeout(cb, 0); + (globalThis as any).cancelIdleCallback = (id: number) => clearTimeout(id); +} + +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isNative: false, + isDesktop: false, + isWeb: true, + isRuntimeBrowser: true, + isRuntimeChrome: false, + }, +})); + +jest.mock('@onekeyhq/kit/src/hooks/useRouteIsFocused', () => ({ + useRouteIsFocused: () => true, +})); + +jest.mock('@onekeyhq/components', () => { + const deferredPromiseModule = require('../../../../../components/src/hooks/useDeferredPromise'); + const netInfoModule = require('../../../../../components/src/hooks/useNetInfo'); + return { + __esModule: true, + getCurrentVisibilityState: () => true, + onVisibilityStateChange: () => () => {}, + useDeferredPromise: deferredPromiseModule.useDeferredPromise, + useNetInfo: netInfoModule.useNetInfo, + }; +}); + +jest.mock('@onekeyhq/kit/src/hooks/usePerpTabConfig', () => ({ + usePerpTabConfig: () => ({ perpDisabled: false }), +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + EnabledNetworksChanged: 'EnabledNetworksChanged', + DeFiEnabledNetworksChanged: 'DeFiEnabledNetworksChanged', + }, + appEventBus: { on: jest.fn(), off: jest.fn() }, +})); + +const mockGetDeFiEnabledNetworksMap: jest.Mock< + Promise>, + unknown[] +> = jest.fn(); + +jest.mock('../../../background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: { + serviceDeFi: { + getDeFiEnabledNetworksMap: (...args: unknown[]) => + mockGetDeFiEnabledNetworksMap(...args), + }, + serviceAllNetwork: { getAllNetworksState: jest.fn() }, + serviceNetwork: { getAllNetworks: jest.fn() }, + }, +})); + +import { + swrCacheUtils, + swrKeys, +} from '@onekeyhq/shared/src/utils/swrCacheUtils'; + +import { useHomeWalletTabSupport } from './useHomeWalletTabSupport'; + +const evmNetwork = { id: 'evm--1', isAllNetworks: false, isTestnet: false }; +const evmScopeKey = 'evm--1:single:perp-enabled'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe('useHomeWalletTabSupport cold start (OK-61505)', () => { + beforeEach(() => { + swrCacheUtils.clearAll(); + mockGetDeFiEnabledNetworksMap.mockReset(); + }); + + it('renders the persisted tab support before the background call resolves', () => { + swrCacheUtils.set(swrKeys.homeWalletTabSupport({ scopeKey: evmScopeKey }), { + scopeKey: evmScopeKey, + isReady: true, + isDeFiSupported: true, + isPerpsSupported: true, + }); + mockGetDeFiEnabledNetworksMap.mockReturnValue( + createDeferred>().promise, + ); + + const { result } = renderHook(() => + useHomeWalletTabSupport({ network: evmNetwork }), + ); + + expect(result.current.isReady).toBe(true); + expect(result.current.isDeFiSupported).toBe(true); + expect(result.current.isPerpsSupported).toBe(true); + }); + + it('starts from init without a snapshot and persists the resolved support', async () => { + const deferred = createDeferred>(); + mockGetDeFiEnabledNetworksMap.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => + useHomeWalletTabSupport({ network: evmNetwork }), + ); + + expect(result.current.isReady).toBe(false); + expect(result.current.isDeFiSupported).toBe(false); + + await act(async () => { + deferred.resolve({ 'evm--1': true }); + await deferred.promise; + }); + + await waitFor(() => { + expect(result.current.isDeFiSupported).toBe(true); + }); + expect( + swrCacheUtils.get( + swrKeys.homeWalletTabSupport({ scopeKey: evmScopeKey }), + ), + ).toEqual({ + scopeKey: evmScopeKey, + isReady: true, + isDeFiSupported: true, + isPerpsSupported: true, + }); + }); + + it('does not persist the placeholder produced while no network is selected', async () => { + const { result } = renderHook(() => + useHomeWalletTabSupport({ network: undefined }), + ); + + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + expect(mockGetDeFiEnabledNetworksMap).not.toHaveBeenCalled(); + expect(swrCacheUtils.get(':single:perp-enabled')).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.test.ts b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.test.ts index 89e64ccede32..e99316a1f31f 100644 --- a/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.test.ts +++ b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.test.ts @@ -1,6 +1,7 @@ import { HOME_WALLET_TAB_SUPPORT_INIT, buildHomeWalletTabSupport, + buildHomeWalletTabSupportScopeKey, hasDeFiSupportedEnabledNetwork, resolveHomeWalletTabSupport, } from './homeWalletTabSupportUtils'; @@ -184,3 +185,29 @@ describe('Home wallet tab support', () => { }); }); }); + +describe('buildHomeWalletTabSupportScopeKey', () => { + it('keys the scope by network, all-networks mode and perp flag only', () => { + expect( + buildHomeWalletTabSupportScopeKey({ + networkId: 'evm--1', + isAllNetworks: false, + perpDisabled: false, + }), + ).toBe('evm--1:single:perp-enabled'); + expect( + buildHomeWalletTabSupportScopeKey({ + networkId: 'onekeyall--0', + isAllNetworks: true, + perpDisabled: true, + }), + ).toBe('onekeyall--0:all:perp-disabled'); + expect( + buildHomeWalletTabSupportScopeKey({ + networkId: undefined, + isAllNetworks: false, + perpDisabled: false, + }), + ).toBe(':single:perp-enabled'); + }); +}); diff --git a/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.ts b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.ts index b9a1dbc01742..1f91d9b1831d 100644 --- a/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.ts +++ b/packages/kit/src/views/Home/hooks/useHomeWalletTabSupport.ts @@ -7,14 +7,15 @@ import { appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; +import { swrKeys } from '@onekeyhq/shared/src/utils/swrCacheUtils'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; import { - HOME_WALLET_TAB_SUPPORT_INIT, type IHomeWalletTabSupportNetwork, type IScopedHomeWalletTabSupportState, buildHomeWalletTabSupport, + buildHomeWalletTabSupportScopeKey, resolveHomeWalletTabSupport, } from './homeWalletTabSupportUtils'; @@ -81,17 +82,27 @@ export function useHomeWalletTabSupport({ }; }, []); + // The scope key carries no re-fetch nonce: the nonce only drives a re-run + // (deps below), while the key must stay stable across sessions so the + // persisted snapshot matches on the next cold start. const scopeKey = useMemo( () => - [ - networkId ?? '', - isAllNetworks ? 'all' : 'single', - perpDisabled ? 'perp-disabled' : 'perp-enabled', - enabledNetworksChangedNonce, - ].join(':'), - [enabledNetworksChangedNonce, isAllNetworks, networkId, perpDisabled], + buildHomeWalletTabSupportScopeKey({ + networkId, + isAllNetworks, + perpDisabled, + }), + [isAllNetworks, networkId, perpDisabled], ); + // Cold start seeds the first frame from the last resolved support for this + // scope so the Perps / DeFi tabs are already in place before the background + // gating round-trip returns (OK-61505). The network-less placeholder is + // never persisted. + const swrKey = networkId + ? swrKeys.homeWalletTabSupport({ scopeKey }) + : undefined; + const { result } = usePromiseResult( async () => { if (!currentNetwork) { @@ -138,12 +149,19 @@ export function useHomeWalletTabSupport({ }), }; }, - [currentNetwork, isAllNetworks, scopeKey, perpDisabled], + // The nonce is a pure re-fetch trigger (enabled networks changed); it is + // intentionally read by no code path so it stays out of the scope key. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + currentNetwork, + isAllNetworks, + scopeKey, + perpDisabled, + enabledNetworksChangedNonce, + ], { - initResult: { - scopeKey, - ...HOME_WALLET_TAB_SUPPORT_INIT, - }, + swrKey, + swrShouldPersist: (value) => value.isReady, undefinedResultIfReRun: true, }, ); diff --git a/packages/shared/src/utils/homeWalletTabSupportUtils.ts b/packages/shared/src/utils/homeWalletTabSupportUtils.ts index 7ede6cb29002..067cff1d4ad7 100644 --- a/packages/shared/src/utils/homeWalletTabSupportUtils.ts +++ b/packages/shared/src/utils/homeWalletTabSupportUtils.ts @@ -28,6 +28,25 @@ export const HOME_WALLET_TAB_SUPPORT_INIT: IHomeWalletTabSupportState = { isPerpsSupported: false, }; +// Persistent scope identity for a tab-support result. Deliberately excludes +// transient re-fetch counters so a snapshot written in one session still +// matches the scope on the next cold start. +export function buildHomeWalletTabSupportScopeKey({ + networkId, + isAllNetworks, + perpDisabled, +}: { + networkId?: string; + isAllNetworks: boolean; + perpDisabled: boolean; +}) { + return [ + networkId ?? '', + isAllNetworks ? 'all' : 'single', + perpDisabled ? 'perp-disabled' : 'perp-enabled', + ].join(':'); +} + export function resolveHomeWalletTabSupport({ result, scopeKey, diff --git a/packages/shared/src/utils/swrCacheUtils.ts b/packages/shared/src/utils/swrCacheUtils.ts index d86bc96acf9f..ad103346f8fa 100644 --- a/packages/shared/src/utils/swrCacheUtils.ts +++ b/packages/shared/src/utils/swrCacheUtils.ts @@ -693,6 +693,7 @@ const NS = { bulkCopyAddressesNetworkIds: 'bulkCopyNetIds', bulkCopyAddressesAccounts: 'bulkCopyAccounts', chainSelectorInputNetworks: 'chainSelNets', + homeWalletTabSupport: 'homeWalletTabs', } as const; export type ISwrCacheNamespace = (typeof NS)[keyof typeof NS]; export const swrCacheNamespaces = NS; @@ -938,6 +939,11 @@ export const swrKeys = { accountId ?? '', ].join(':'), defiEnabled: (networkId: string) => `defiEnabled:${networkId}`, + // Home wallet tab support (Perps / DeFi tab visibility). Seeds the first + // frame so the tab row does not reflow once the background gating resolves + // (OK-61505). scopeKey = buildHomeWalletTabSupportScopeKey(). + homeWalletTabSupport: ({ scopeKey }: { scopeKey: string }) => + [NS.homeWalletTabSupport, 'v1', scopeKey].join(':'), discoveryHomePageData: () => [NS.discoveryHomePageData, 'v1'].join(':'), discoveryHomeBookmarks: () => [NS.discoveryHomeBookmarks, 'v1'].join(':'), // Account selector left sidebar wallet list. One slot per From 74f374a4da116bd47c82e79e4dc933b5f55a7c0c Mon Sep 17 00:00:00 2001 From: weatherstar Date: Mon, 7 Sep 2026 17:10:45 +0800 Subject: [PATCH 5/9] fix: hide asset value on networks without a created address in aggregate token selector (OK-61879) --- .../pages/AggregateTokenSelector.test.tsx | 269 ++++++++++++++++++ .../pages/AggregateTokenSelector.tsx | 20 +- 2 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.test.tsx diff --git a/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.test.tsx b/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.test.tsx new file mode 100644 index 000000000000..d00603cc56a9 --- /dev/null +++ b/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.test.tsx @@ -0,0 +1,269 @@ +/** @jest-environment jsdom */ + +import type { ReactNode } from 'react'; + +import { render, waitFor } from '@testing-library/react'; + +import type { IAccountToken } from '@onekeyhq/shared/types/token'; + +import { AggregateTokenListItem } from './AggregateTokenSelector'; + +const mockGetNetworkAccount = jest.fn< + Promise<{ id: string } | undefined>, + [unknown] +>(); +let mockSubTokenFiat: + | { balanceParsed?: string; fiatValue?: string; currency?: string } + | undefined; + +jest.mock('@react-navigation/core', () => ({ + useRoute: () => ({ params: {} }), +})); + +jest.mock('react-intl', () => ({ + useIntl: () => ({ + formatMessage: ({ id }: { id: string }) => id, + }), +})); + +jest.mock('@onekeyhq/components', () => { + const React = jest.requireActual('react'); + const Container = ({ children }: { children?: ReactNode }) => + React.createElement('div', null, children); + return { + Empty: () => null, + Icon: ({ name }: { name: string }) => + React.createElement('span', { 'data-testid': `icon-${name}` }), + NumberSizeableText: ({ children }: { children?: ReactNode }) => + React.createElement('span', { 'data-testid': 'balance' }, children), + Page: Object.assign(Container, { Header: () => null, Body: Container }), + Spinner: () => React.createElement('span', { 'data-testid': 'spinner' }), + Stack: Container, + Toast: { success: jest.fn(), error: jest.fn() }, + }; +}); + +jest.mock('@onekeyhq/kit/src/components/Currency', () => { + const React = jest.requireActual('react'); + return { + Currency: ({ children }: { children?: ReactNode }) => + React.createElement('span', { 'data-testid': 'fiat-value' }, children), + }; +}); + +jest.mock('@onekeyhq/shared/src/config/networkIds', () => ({ + getListedNetworkMap: () => ({}), +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: {}, + appEventBus: { emit: jest.fn() }, +})); + +jest.mock('../../../background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: { + serviceNetwork: { + getGlobalDeriveTypeOfNetwork: jest.fn(async () => 'default'), + }, + serviceAccount: { + getNetworkAccount: (params: unknown) => mockGetNetworkAccount(params), + }, + }, +})); + +jest.mock('../../../components/AccountSelector/AccountSelectorProvider', () => { + const React = jest.requireActual('react'); + return { + AccountSelectorProviderMirror: ({ children }: { children?: ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +jest.mock( + '../../../components/AccountSelector/hooks/useAccountSelectorCreateAddress', + () => ({ + useAccountSelectorCreateAddress: () => ({ createAddress: jest.fn() }), + }), +); + +jest.mock('../../../components/Empty', () => ({ + EmptySearch: () => null, +})); + +jest.mock('../../../components/ListItem', () => { + const React = jest.requireActual('react'); + const ListItem = Object.assign( + ({ + title, + subtitle, + children, + }: { + title?: ReactNode; + subtitle?: ReactNode; + children?: ReactNode; + }) => + React.createElement( + 'div', + { 'data-testid': 'list-item' }, + React.createElement('span', { 'data-testid': 'title' }, title), + subtitle + ? React.createElement('span', { 'data-testid': 'subtitle' }, subtitle) + : null, + children, + ), + { + Text: ({ + primary, + secondary, + }: { + primary?: ReactNode; + secondary?: ReactNode; + }) => + React.createElement( + 'div', + { 'data-testid': 'list-item-text' }, + primary, + secondary, + ), + }, + ); + return { ListItem }; +}); + +jest.mock('../../../components/NetworkAvatar', () => ({ + NetworkAvatarBase: () => null, +})); + +jest.mock('../../../hooks/useAppNavigation', () => ({ + __esModule: true, + default: () => ({ pop: jest.fn(), push: jest.fn() }), +})); + +jest.mock('../../../hooks/usePromiseResult', () => { + const React = jest.requireActual('react'); + return { + // Minimal stand-in that runs the loader once per deps change so the row's + // account lookup settles asynchronously, the same way it does at runtime. + usePromiseResult: (fn: () => Promise, deps: unknown[]) => { + const [result, setResult] = React.useState(undefined); + React.useEffect(() => { + let cancelled = false; + void fn().then((value) => { + if (!cancelled) { + setResult(value); + } + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return { result, run: jest.fn() }; + }, + }; +}); + +jest.mock('../../../states/jotai/contexts/accountSelector', () => ({ + useActiveAccount: () => ({ + activeAccount: { + wallet: { id: 'hd-1' }, + indexedAccount: { id: 'hd-1--0' }, + }, + }), +})); + +jest.mock('../../../states/jotai/contexts/tokenList', () => ({ + useProcessingTokenStateAtom: () => [{ isProcessing: false, token: null }], + useTokenListActions: () => ({ + current: { updateProcessingTokenState: jest.fn() }, + }), +})); + +jest.mock('../../../states/jotai/contexts/tokenList/cells', () => ({ + useAggregateSubTokenFiat: () => mockSubTokenFiat, + useAggregateSubTokenFiatMap: () => ({}), +})); + +jest.mock('../../Home/components/HomeTokenListProvider', () => { + const React = jest.requireActual('react'); + return { + HomeTokenListProviderMirrorWrapper: ({ + children, + }: { + children?: ReactNode; + }) => React.createElement(React.Fragment, null, children), + }; +}); + +const suiUsdc: IAccountToken = { + $key: 'sui--mainnet_usdc', + address: '0xusdc', + decimals: 6, + isNative: false, + name: 'USD Coin', + symbol: 'USDC', + networkId: 'sui--mainnet', + networkName: 'SUI', +}; + +function renderRow(token: IAccountToken = suiUsdc) { + return render( + , + ); +} + +describe('AggregateTokenListItem', () => { + beforeEach(() => { + mockGetNetworkAccount.mockReset(); + mockSubTokenFiat = undefined; + }); + + it('hides balance and value on a network without a created address (OK-61879)', async () => { + mockGetNetworkAccount.mockRejectedValue(new Error('account not found')); + + const { queryByTestId } = renderRow(); + + await waitFor(() => expect(mockGetNetworkAccount).toHaveBeenCalled()); + await waitFor(() => expect(queryByTestId('list-item-text')).toBeNull()); + expect(queryByTestId('subtitle')?.textContent).toBe( + 'global.create_address', + ); + expect(queryByTestId('icon-PlusLargeOutline')).not.toBeNull(); + }); + + it('keeps balance and value once the network account resolves', async () => { + mockGetNetworkAccount.mockResolvedValue({ id: 'hd-1--sui-0' }); + mockSubTokenFiat = { + balanceParsed: '0.414', + fiatValue: '0.41', + currency: 'usd', + }; + + const { queryByTestId, getByTestId } = renderRow(); + + await waitFor(() => expect(queryByTestId('subtitle')).toBeNull()); + expect(getByTestId('list-item-text')).not.toBeNull(); + expect(getByTestId('balance').textContent).toBe('0.414'); + expect(getByTestId('fiat-value').textContent).toBe('0.41'); + expect(queryByTestId('icon-PlusLargeOutline')).toBeNull(); + }); + + it('keeps the value column while the account lookup is still pending', () => { + mockGetNetworkAccount.mockReturnValue(new Promise(() => {})); + + const { getByTestId } = renderRow(); + + // Rows with an address must not blink their balance in after the first + // frame, so the column stays until the lookup settles without an account. + expect(getByTestId('list-item-text')).not.toBeNull(); + }); +}); diff --git a/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.tsx b/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.tsx index 2e5ec3f39ebe..ef24babfe62f 100644 --- a/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.tsx +++ b/packages/kit/src/views/AssetSelector/pages/AggregateTokenSelector.tsx @@ -62,7 +62,7 @@ import type { RouteProp } from '@react-navigation/core'; // list does not flash empty while the dynamic (server-fetched) networks resolve. const listedNetworkMap = getListedNetworkMap(); -function AggregateTokenListItem({ +export function AggregateTokenListItem({ token, aggKey, network, @@ -111,9 +111,11 @@ function AggregateTokenListItem({ const { createAddress } = useAccountSelectorCreateAddress(); - const { result: accountId, run } = usePromiseResult(async () => { + // Settles to an object so a pending lookup (undefined) stays distinguishable + // from a settled "no address on this network" ({ accountId: undefined }). + const { result: networkAccountLookup, run } = usePromiseResult(async () => { if (token.accountId) { - return token.accountId; + return { accountId: token.accountId }; } const deriveType = @@ -130,11 +132,17 @@ function AggregateTokenListItem({ }, ); - return account?.id; + return { accountId: account?.id }; } catch { - return undefined; + return { accountId: undefined }; } }, [indexedAccount?.id, token.networkId, token.accountId]); + const accountId = networkAccountLookup?.accountId; + // OK-61879: a network with no created address has no balance to show, so + // the value column is dropped once the lookup settles without an account. + // While pending it stays put, so rows that do have an address never blink + // their balance in after the first frame. + const isAddressMissing = networkAccountLookup !== undefined && !accountId; const handleOnPress = useCallback(async () => { if (accountId) { @@ -241,7 +249,7 @@ function AggregateTokenListItem({ : intl.formatMessage({ id: ETranslations.global_create_address }), })} > - {hideBalanceAndValue ? null : ( + {hideBalanceAndValue || isAddressMissing ? null : ( Date: Mon, 7 Sep 2026 18:15:54 +0800 Subject: [PATCH 6/9] fix: enable only the landed network when jumping aggregate token tabs (OK-61863) --- .../AssetDetails/pages/TokenDetails/index.tsx | 67 +++++----- .../tokenDetailsNetworkAutoEnable.test.ts | 122 ++++++++++++++++++ .../tokenDetailsNetworkAutoEnable.ts | 52 ++++++++ ...ct-native-collapsible-tab-view+8.0.1.patch | 91 +++++++++---- 4 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.test.ts create mode 100644 packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.ts diff --git a/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx b/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx index a80640700ac8..36bd02f80838 100644 --- a/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx +++ b/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx @@ -57,7 +57,6 @@ import type { IModalAssetDetailsParamList, } from '@onekeyhq/shared/src/routes/assetDetails'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; -import { isEnabledNetworksInAllNetworks } from '@onekeyhq/shared/src/utils/networkUtils'; import { waitAsync } from '@onekeyhq/shared/src/utils/promiseUtils'; import { buildTokenListMapKey, @@ -79,6 +78,7 @@ import { useTokenDetailsContext, } from './TokenDetailsContext'; import TokenDetailsFooter from './TokenDetailsFooter'; +import { enableNetworkInAllNetworksOnce } from './tokenDetailsNetworkAutoEnable'; import TokenDetailsOverview from './TokenDetailsOverview'; import TokenDetailsTabToolbar from './TokenDetailsTabToolbar'; import TokenDetailsViews from './TokenDetailsView'; @@ -753,44 +753,49 @@ function TokenDetailsView() { const pageWidth = useTabletModalPageWidth(); + // The native Tabs container captures onIndexChange once, so read the tab + // list through a ref instead of trusting the closure to stay current. + const aggregateTabsRef = useRef(aggregateTabs); + aggregateTabsRef.current = aggregateTabs; + const enablingNetworkIdsRef = useRef>(new Set()); + const handleTabIndexChange = useCallback( async (index: number) => { setActiveTabIndex(index); // The Overview descriptor has no token, so only member tabs can trigger // the auto-enable below. - const activeToken = aggregateTabs?.[index]?.token; - if ( - isAllNetworks && - activeToken?.accountId && - activeToken.networkId && - !isEnabledNetworksInAllNetworks({ - networkId: activeToken.networkId, - disabledNetworks: allNetworksState.disabledNetworks, - enabledNetworks: allNetworksState.enabledNetworks, - isTestnet: false, - }) - ) { - await backgroundApiProxy.serviceAllNetwork.updateAllNetworksState({ - enabledNetworks: { [activeToken.networkId]: true }, - }); - appEventBus.emit(EAppEventBusNames.AccountDataUpdate, undefined); - Toast.success({ - title: intl.formatMessage({ - id: ETranslations.network_also_enabled, - }), - }); - void refreshAllNetworkState(); + const activeToken = aggregateTabsRef.current?.[index]?.token; + const activeNetworkId = activeToken?.networkId; + if (!isAllNetworks || !activeToken?.accountId || !activeNetworkId) { + return; } + // OK-61863: decide against the live All-Networks state rather than the + // render snapshot, so a network enabled by an earlier tab switch is + // never enabled (and toasted) a second time. + const enabled = await enableNetworkInAllNetworksOnce({ + networkId: activeNetworkId, + inFlightNetworkIds: enablingNetworkIdsRef.current, + getAllNetworksState: () => + backgroundApiProxy.serviceAllNetwork.getAllNetworksState(), + enableNetwork: async (targetNetworkId) => { + await backgroundApiProxy.serviceAllNetwork.updateAllNetworksState({ + enabledNetworks: { [targetNetworkId]: true }, + }); + }, + }); + if (!enabled) { + return; + } + appEventBus.emit(EAppEventBusNames.AccountDataUpdate, undefined); + Toast.success({ + title: intl.formatMessage({ + id: ETranslations.network_also_enabled, + }), + }); + void refreshAllNetworkState(); }, - [ - isAllNetworks, - aggregateTabs, - allNetworksState.disabledNetworks, - allNetworksState.enabledNetworks, - intl, - refreshAllNetworkState, - ], + [isAllNetworks, intl, refreshAllNetworkState], ); const tokenDetailsViewElement = useMemo(() => { diff --git a/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.test.ts b/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.test.ts new file mode 100644 index 000000000000..98f2788bff68 --- /dev/null +++ b/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.test.ts @@ -0,0 +1,122 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { enableNetworkInAllNetworksOnce } from './tokenDetailsNetworkAutoEnable'; + +// Not in the default-enabled preset set, so only `enabledNetworks` governs it. +const networkId = 'evm--999999'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe('enableNetworkInAllNetworksOnce (OK-61863)', () => { + it('enables a network the live state reports as disabled', async () => { + const enableNetwork = jest.fn(async () => {}); + + const enabled = await enableNetworkInAllNetworksOnce({ + networkId, + inFlightNetworkIds: new Set(), + getAllNetworksState: async () => ({ + disabledNetworks: {}, + enabledNetworks: {}, + }), + enableNetwork, + }); + + expect(enabled).toBe(true); + expect(enableNetwork).toHaveBeenCalledTimes(1); + expect(enableNetwork).toHaveBeenCalledWith(networkId); + }); + + it('consults the live state instead of a render snapshot', async () => { + const enableNetwork = jest.fn(async () => {}); + + // A stale snapshot would still say "disabled" here; the live read says + // an earlier tab switch already enabled it, so nothing must happen. + const enabled = await enableNetworkInAllNetworksOnce({ + networkId, + inFlightNetworkIds: new Set(), + getAllNetworksState: async () => ({ + disabledNetworks: {}, + enabledNetworks: { [networkId]: true }, + }), + enableNetwork, + }); + + expect(enabled).toBe(false); + expect(enableNetwork).not.toHaveBeenCalled(); + }); + + it('collapses overlapping calls for the same network into one enable', async () => { + const stateRead = createDeferred<{ + disabledNetworks: Record; + enabledNetworks: Record; + }>(); + const enableNetwork = jest.fn(async () => {}); + const inFlightNetworkIds = new Set(); + const params = { + networkId, + inFlightNetworkIds, + getAllNetworksState: () => stateRead.promise, + enableNetwork, + }; + + const first = enableNetworkInAllNetworksOnce(params); + const second = enableNetworkInAllNetworksOnce(params); + stateRead.resolve({ disabledNetworks: {}, enabledNetworks: {} }); + + await expect(Promise.all([first, second])).resolves.toEqual([true, false]); + expect(enableNetwork).toHaveBeenCalledTimes(1); + expect(inFlightNetworkIds.size).toBe(0); + }); + + it('releases the in-flight marker when the enable call throws', async () => { + const inFlightNetworkIds = new Set(); + + await expect( + enableNetworkInAllNetworksOnce({ + networkId, + inFlightNetworkIds, + getAllNetworksState: async () => ({ + disabledNetworks: {}, + enabledNetworks: {}, + }), + enableNetwork: async () => { + throw new OneKeyLocalError('boom'); + }, + }), + ).rejects.toThrow('boom'); + + expect(inFlightNetworkIds.size).toBe(0); + }); + + it('does not block a different network that is enabling concurrently', async () => { + const stateRead = createDeferred<{ + disabledNetworks: Record; + enabledNetworks: Record; + }>(); + const enableNetwork = jest.fn(async () => {}); + const inFlightNetworkIds = new Set(); + + const first = enableNetworkInAllNetworksOnce({ + networkId, + inFlightNetworkIds, + getAllNetworksState: () => stateRead.promise, + enableNetwork, + }); + const second = enableNetworkInAllNetworksOnce({ + networkId: 'evm--888888', + inFlightNetworkIds, + getAllNetworksState: () => stateRead.promise, + enableNetwork, + }); + stateRead.resolve({ disabledNetworks: {}, enabledNetworks: {} }); + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(enableNetwork).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.ts b/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.ts new file mode 100644 index 000000000000..15f0603ecf23 --- /dev/null +++ b/packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.ts @@ -0,0 +1,52 @@ +import { isEnabledNetworksInAllNetworks } from '@onekeyhq/shared/src/utils/networkUtils'; + +export type IAllNetworksEnableState = { + disabledNetworks: Record; + enabledNetworks: Record; +}; + +/** + * Enable `networkId` in All Networks unless the live state already has it on. + * + * The decision is made against a fresh state read, never a render snapshot: + * the native Tabs container captures the index-change callback once, and + * several tab switches can land before the snapshot refreshes, so a snapshot + * cannot tell whether an earlier switch already enabled this network + * (OK-61863). Overlapping calls for the same network collapse into one enable. + * + * Resolves to `true` only when this call performed the enable, so the caller + * can toast and refresh exactly once. + */ +export async function enableNetworkInAllNetworksOnce({ + networkId, + inFlightNetworkIds, + getAllNetworksState, + enableNetwork, +}: { + networkId: string; + inFlightNetworkIds: Set; + getAllNetworksState: () => Promise; + enableNetwork: (networkId: string) => Promise; +}): Promise { + if (inFlightNetworkIds.has(networkId)) { + return false; + } + inFlightNetworkIds.add(networkId); + try { + const { disabledNetworks, enabledNetworks } = await getAllNetworksState(); + if ( + isEnabledNetworksInAllNetworks({ + networkId, + disabledNetworks, + enabledNetworks, + isTestnet: false, + }) + ) { + return false; + } + await enableNetwork(networkId); + return true; + } finally { + inFlightNetworkIds.delete(networkId); + } +} diff --git a/patches/react-native-collapsible-tab-view+8.0.1.patch b/patches/react-native-collapsible-tab-view+8.0.1.patch index d428beb9974e..f86d42b9c018 100644 --- a/patches/react-native-collapsible-tab-view+8.0.1.patch +++ b/patches/react-native-collapsible-tab-view+8.0.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-collapsible-tab-view/lib/typescript/src/index.d.ts b/node_modules/react-native-collapsible-tab-view/lib/typescript/src/index.d.ts -index 83c5549..2e46dbb 100644 +index 83c5549..f0b35e5 100644 --- a/node_modules/react-native-collapsible-tab-view/lib/typescript/src/index.d.ts +++ b/node_modules/react-native-collapsible-tab-view/lib/typescript/src/index.d.ts @@ -35,7 +35,7 @@ export declare const Tabs: { @@ -46,7 +46,7 @@ index 7a739fc..60d1ba0 100644 export type ScrollViewProps = ComponentProps; export type CollapsibleStyle = { diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx -index e782b90..47a1857 100644 +index e782b90..dd98965 100644 --- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx +++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx @@ -1,9 +1,15 @@ @@ -137,23 +137,28 @@ index e782b90..47a1857 100644 ) // derived from scrollX -@@ -194,6 +210,15 @@ export const Container = React.memo( +@@ -193,7 +209,19 @@ export const Container = React.memo( + return nextIndex }, (nextIndex) => { - if (nextIndex !== null && nextIndex !== index.value) { -+ if ( -+ programmaticPageTarget.value !== null && -+ nextIndex !== programmaticPageTarget.value -+ ) { +- if (nextIndex !== null && nextIndex !== index.value) { ++ if (nextIndex === null) { ++ return ++ } ++ // OneKey patch: release the programmatic target as soon as the pager ++ // visually reaches it, even when `index` was already synced by the ++ // early Android onPageSelected, so the guard never outlives the scroll. ++ if (programmaticPageTarget.value !== null) { ++ if (nextIndex !== programmaticPageTarget.value) { + return + } -+ if (nextIndex === programmaticPageTarget.value) { -+ programmaticPageTarget.value = null -+ } ++ programmaticPageTarget.value = null ++ } ++ if (nextIndex !== index.value) { calculateNextOffset.value = nextIndex } }, -@@ -212,25 +237,46 @@ export const Container = React.memo( +@@ -212,25 +240,46 @@ export const Container = React.memo( 'worklet' const name = tabNamesArray[index.value] @@ -206,7 +211,7 @@ index e782b90..47a1857 100644 runOnJS(toggleSyncScrollFrame)(false) } }, false) -@@ -241,54 +287,298 @@ export const Container = React.memo( +@@ -241,54 +290,334 @@ export const Container = React.memo( }, (i) => { if (i !== index.value) { @@ -380,7 +385,8 @@ index e782b90..47a1857 100644 + } + // Start first attempt after a short delay + setTimeout(() => scrollToPosition(0), 16) -+ } + } +- }, [revealHeaderOnScroll]) + }, [useNativeHeaderAnimation, nativeScrollY, contentInset]) + + // Only sync nativeScrollY when focused tab changes, not on every scroll @@ -444,8 +450,7 @@ index e782b90..47a1857 100644 + if (pendingPageFallbackTimerRef.current) { + clearTimeout(pendingPageFallbackTimerRef.current) + pendingPageFallbackTimerRef.current = null - } -- }, [revealHeaderOnScroll]) ++ } + }, []) + + React.useEffect( @@ -460,12 +465,27 @@ index e782b90..47a1857 100644 + const selectedIndex = event.nativeEvent.position + const pendingTarget = programmaticPageTarget.value + if (pendingTarget !== null || pendingPageFallbackTimerRef.current) { ++ // OneKey patch: Android's ViewPager2 reports the selected page as ++ // soon as a programmatic smooth scroll starts, before the pager has ++ // moved. Sync `index` right away (keeps the tab highlight instant) ++ // but keep the programmatic target alive until the pager visually ++ // reaches the page; otherwise every page the scroll passes through ++ // would be propagated as an index change. Landing on a different ++ // page (the user interrupted the scroll) still releases the target. ++ const isEarlyProgrammaticSelect = ++ pendingTarget !== null && ++ selectedIndex === pendingTarget && ++ Math.round(indexDecimal.value) !== selectedIndex + if (index.value !== selectedIndex) { + calculateNextOffset.value = selectedIndex -+ indexDecimal.value = selectedIndex ++ if (!isEarlyProgrammaticSelect) { ++ indexDecimal.value = selectedIndex ++ } ++ } ++ if (!isEarlyProgrammaticSelect) { ++ programmaticPageTarget.value = null ++ clearPendingPageFallback() + } -+ programmaticPageTarget.value = null -+ clearPendingPageFallback() + } + pagerProps?.onPageSelected?.(event) + }, @@ -475,13 +495,28 @@ index e782b90..47a1857 100644 + const handlePageScrollStateChanged = React.useCallback( + (event: PageScrollStateChangedNativeEvent) => { + const pendingTarget = programmaticPageTarget.value -+ if (pendingTarget !== null && index.value === pendingTarget) { ++ const pageScrollState = event.nativeEvent.pageScrollState ++ // OneKey patch: `settling` is dispatched while the programmatic scroll ++ // is still in flight (Android emits it right before the early ++ // onPageSelected), so only a non-settling state marks the pager as ++ // done with the programmatic target. ++ if ( ++ pendingTarget !== null && ++ index.value === pendingTarget && ++ pageScrollState !== 'settling' ++ ) { ++ if ( ++ pageScrollState === 'idle' && ++ Math.round(indexDecimal.value) !== pendingTarget ++ ) { ++ indexDecimal.value = pendingTarget ++ } + programmaticPageTarget.value = null + clearPendingPageFallback() + } + pagerProps?.onPageScrollStateChanged?.(event) + }, -+ [clearPendingPageFallback, index, pagerProps, programmaticPageTarget] ++ [clearPendingPageFallback, index, indexDecimal, pagerProps, programmaticPageTarget] + ) + + const setPageWithFallback = React.useCallback( @@ -506,6 +541,12 @@ index e782b90..47a1857 100644 + pendingPageFallbackTimerRef.current = setTimeout(() => { + pendingPageFallbackTimerRef.current = null + if (index.value === nextIndex) { ++ // OneKey patch: `index` may have been synced by an early ++ // onPageSelected while the pager never scrolled; finish the ++ // arrival here so the tab indicator lands on the target too. ++ if (Math.round(indexDecimal.value) !== nextIndex) { ++ indexDecimal.value = nextIndex ++ } + programmaticPageTarget.value = null + return + } @@ -530,7 +571,7 @@ index e782b90..47a1857 100644 const onTabPress = React.useCallback( (name: TabName) => { -@@ -303,11 +593,11 @@ export const Container = React.memo( +@@ -303,11 +632,11 @@ export const Container = React.memo( true ) } else { @@ -544,7 +585,7 @@ index e782b90..47a1857 100644 ) useAnimatedReaction( -@@ -344,57 +634,96 @@ export const Container = React.memo( +@@ -344,57 +673,96 @@ export const Container = React.memo( getCurrentIndex: () => { return index.value }, @@ -680,7 +721,7 @@ index e782b90..47a1857 100644 pointerEvents="box-none" > {renderHeader && -@@ -407,10 +736,11 @@ export const Container = React.memo( +@@ -407,10 +775,11 @@ export const Container = React.memo( onTabPress, tabProps, })} @@ -694,7 +735,7 @@ index e782b90..47a1857 100644 pointerEvents="box-none" > {renderTabBar && -@@ -425,13 +755,15 @@ export const Container = React.memo( +@@ -425,13 +794,15 @@ export const Container = React.memo( tabProps, })} From cece75ca04c791937055e674242b68fcae8298bf Mon Sep 17 00:00:00 2001 From: weatherstar Date: Mon, 7 Sep 2026 19:28:21 +0800 Subject: [PATCH 7/9] fix: size aggregate token network sheets to their content (OK-61860) --- .../src/actions/Popover/index.native.tsx | 30 +++++++++++++++++-- .../components/src/actions/Popover/index.tsx | 30 +++++++++++++++++-- .../TokenDetails/TokenDetailsTabToolbar.tsx | 4 --- .../AssetDetails/pages/TokenDetails/index.tsx | 4 --- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/components/src/actions/Popover/index.native.tsx b/packages/components/src/actions/Popover/index.native.tsx index 9ec2bc6eb295..0f92b9424125 100644 --- a/packages/components/src/actions/Popover/index.native.tsx +++ b/packages/components/src/actions/Popover/index.native.tsx @@ -8,7 +8,7 @@ import type { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useIsomorphicLayoutEffect } from '@tamagui/core'; -import { Dimensions } from 'react-native'; +import { Dimensions, useWindowDimensions } from 'react-native'; import { useMedia } from '@onekeyhq/components/src/hooks/useStyle'; import { withStaticProperties } from '@onekeyhq/components/src/shared/tamagui'; @@ -52,7 +52,7 @@ import { useNativePortalLifecycle } from './useNativePortalLifecycle'; import type { IPopoverTooltip } from './type'; import type { IIconButtonProps } from '../IconButton'; -import type { View } from 'react-native'; +import type { LayoutChangeEvent, View } from 'react-native'; const gtMdShFrameStyle = { minWidth: 400, @@ -60,6 +60,15 @@ const gtMdShFrameStyle = { mx: 'auto', } as const; +// Fit-mode sheets size their frame to the content, and the sheet only caps the +// inner ScrollView at the full screen height, so a tall list plus the header +// pushes the frame past the screen (header under the status bar, last rows +// clipped). Keep the whole frame within the footprint percent-mode sheets use, +// so short lists stay compact and long lists scroll. +const FIT_SHEET_MAX_HEIGHT_RATIO = 0.92; +// Matches the `$5` fallback margin under the sheet ScrollView. +const SHEET_BOTTOM_MARGIN = 20; + const POPOVER_ENTER_STYLE = { scale: 0.95, opacity: 0 } as const; const POPOVER_EXIT_STYLE = { scale: 0.95, opacity: 0 } as const; const POPOVER_PLATFORM_WEB_STYLE = { @@ -269,6 +278,21 @@ function RawPopover({ ...props }: IPopoverProps) { const { bottom } = useSafeAreaInsets(); + const { height: viewportHeight } = useWindowDimensions(); + const [sheetHeaderHeight, setSheetHeaderHeight] = useState(0); + const handleSheetHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setSheetHeaderHeight(Math.ceil(event.nativeEvent.layout.height)); + }, []); + const isFitSheet = + !sheetProps?.snapPointsMode || sheetProps.snapPointsMode === 'fit'; + const sheetScrollViewMaxHeight = isFitSheet + ? Math.max( + 0, + Math.floor(viewportHeight * FIT_SHEET_MAX_HEIGHT_RATIO) - + sheetHeaderHeight - + (bottom || SHEET_BOTTOM_MARGIN), + ) + : undefined; const triggerRef = useRef(null); const contentRef = useRef(null); const placement = getPlacement(placementProp, triggerRef); @@ -542,6 +566,7 @@ function RawPopover({ {/* header */} {showHeader ? ( {content} diff --git a/packages/components/src/actions/Popover/index.tsx b/packages/components/src/actions/Popover/index.tsx index 5704edd7b597..52119ee40e0f 100644 --- a/packages/components/src/actions/Popover/index.tsx +++ b/packages/components/src/actions/Popover/index.tsx @@ -8,7 +8,7 @@ import type { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useIsomorphicLayoutEffect } from '@tamagui/core'; -import { Dimensions } from 'react-native'; +import { Dimensions, useWindowDimensions } from 'react-native'; import { useMedia } from '@onekeyhq/components/src/hooks/useStyle'; import { withStaticProperties } from '@onekeyhq/components/src/shared/tamagui'; @@ -51,7 +51,7 @@ import { import type { IPopoverTooltip } from './type'; import type { IIconButtonProps } from '../IconButton'; -import type { View } from 'react-native'; +import type { LayoutChangeEvent, View } from 'react-native'; const gtMdShFrameStyle = { minWidth: 400, @@ -59,6 +59,15 @@ const gtMdShFrameStyle = { mx: 'auto', } as const; +// Fit-mode sheets size their frame to the content, and the sheet only caps the +// inner ScrollView at the full screen height, so a tall list plus the header +// pushes the frame past the screen (header under the status bar, last rows +// clipped). Keep the whole frame within the footprint percent-mode sheets use, +// so short lists stay compact and long lists scroll. +const FIT_SHEET_MAX_HEIGHT_RATIO = 0.92; +// Matches the `$5` fallback margin under the sheet ScrollView. +const SHEET_BOTTOM_MARGIN = 20; + const POPOVER_ENTER_STYLE = { scale: 0.95, opacity: 0 } as const; const POPOVER_EXIT_STYLE = { scale: 0.95, opacity: 0 } as const; const POPOVER_PLATFORM_WEB_STYLE = { @@ -263,6 +272,21 @@ function RawPopover({ ...props }: IPopoverProps) { const { bottom } = useSafeAreaInsets(); + const { height: viewportHeight } = useWindowDimensions(); + const [sheetHeaderHeight, setSheetHeaderHeight] = useState(0); + const handleSheetHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setSheetHeaderHeight(Math.ceil(event.nativeEvent.layout.height)); + }, []); + const isFitSheet = + !sheetProps?.snapPointsMode || sheetProps.snapPointsMode === 'fit'; + const sheetScrollViewMaxHeight = isFitSheet + ? Math.max( + 0, + Math.floor(viewportHeight * FIT_SHEET_MAX_HEIGHT_RATIO) - + sheetHeaderHeight - + (bottom || SHEET_BOTTOM_MARGIN), + ) + : undefined; const triggerRef = useRef(null); const contentRef = useRef(null); const placement = getPlacement(placementProp, triggerRef); @@ -519,6 +543,7 @@ function RawPopover({ {/* header */} {showHeader ? ( {content} diff --git a/packages/kit/src/views/AssetDetails/pages/TokenDetails/TokenDetailsTabToolbar.tsx b/packages/kit/src/views/AssetDetails/pages/TokenDetails/TokenDetailsTabToolbar.tsx index ff271fd8d0c1..1e41174354c9 100644 --- a/packages/kit/src/views/AssetDetails/pages/TokenDetails/TokenDetailsTabToolbar.tsx +++ b/packages/kit/src/views/AssetDetails/pages/TokenDetails/TokenDetailsTabToolbar.tsx @@ -161,10 +161,6 @@ function TokenDetailsTabToolbar(props: IProps) { width: 320, maxHeight: 372, }} - sheetProps={{ - snapPoints: [92], - snapPointsMode: 'percent', - }} title={intl.formatMessage({ id: ETranslations.global_select_network, })} diff --git a/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx b/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx index 36bd02f80838..1b8c21ec2072 100644 --- a/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx +++ b/packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx @@ -447,10 +447,6 @@ function TokenDetailsView() { title={intl.formatMessage({ id: ETranslations.global_contract_address, })} - sheetProps={{ - snapPoints: [92], - snapPointsMode: 'percent', - }} renderTrigger={ Date: Tue, 8 Sep 2026 10:36:25 +0800 Subject: [PATCH 8/9] fix: reserve keyboard height in fit sheet scroll cap (OK-61860) The sheet frame pads its bottom by the keyboard height, so the fit-mode ScrollView cap must subtract it too, or a tall sheet with an auto-focused search field grows past the 92% viewport cap while the keyboard is open. --- packages/components/src/actions/Popover/index.native.tsx | 7 +++++-- packages/components/src/actions/Popover/index.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/components/src/actions/Popover/index.native.tsx b/packages/components/src/actions/Popover/index.native.tsx index 0f92b9424125..57ae4bc8fbe0 100644 --- a/packages/components/src/actions/Popover/index.native.tsx +++ b/packages/components/src/actions/Popover/index.native.tsx @@ -283,14 +283,18 @@ function RawPopover({ const handleSheetHeaderLayout = useCallback((event: LayoutChangeEvent) => { setSheetHeaderHeight(Math.ceil(event.nativeEvent.layout.height)); }, []); + const keyboardHeight = useKeyboardHeight(); const isFitSheet = !sheetProps?.snapPointsMode || sheetProps.snapPointsMode === 'fit'; + // The sheet frame pads its bottom by the keyboard height, so reserve that + // space here too or the frame grows past the cap while the keyboard is open. const sheetScrollViewMaxHeight = isFitSheet ? Math.max( 0, Math.floor(viewportHeight * FIT_SHEET_MAX_HEIGHT_RATIO) - sheetHeaderHeight - - (bottom || SHEET_BOTTOM_MARGIN), + (bottom || SHEET_BOTTOM_MARGIN) - + keyboardHeight, ) : undefined; const triggerRef = useRef(null); @@ -389,7 +393,6 @@ function RawPopover({ const shouldUseWebKeepMountedTransition = keepChildrenMounted && !platformEnv.isNative; const shouldAnimateContent = !keepChildrenMounted; - const keyboardHeight = useKeyboardHeight(); const zIndex = useOverlayZIndex(isOpen); const content = ( diff --git a/packages/components/src/actions/Popover/index.tsx b/packages/components/src/actions/Popover/index.tsx index 52119ee40e0f..007afd41e535 100644 --- a/packages/components/src/actions/Popover/index.tsx +++ b/packages/components/src/actions/Popover/index.tsx @@ -277,14 +277,18 @@ function RawPopover({ const handleSheetHeaderLayout = useCallback((event: LayoutChangeEvent) => { setSheetHeaderHeight(Math.ceil(event.nativeEvent.layout.height)); }, []); + const keyboardHeight = useKeyboardHeight(); const isFitSheet = !sheetProps?.snapPointsMode || sheetProps.snapPointsMode === 'fit'; + // The sheet frame pads its bottom by the keyboard height, so reserve that + // space here too or the frame grows past the cap while the keyboard is open. const sheetScrollViewMaxHeight = isFitSheet ? Math.max( 0, Math.floor(viewportHeight * FIT_SHEET_MAX_HEIGHT_RATIO) - sheetHeaderHeight - - (bottom || SHEET_BOTTOM_MARGIN), + (bottom || SHEET_BOTTOM_MARGIN) - + keyboardHeight, ) : undefined; const triggerRef = useRef(null); @@ -383,7 +387,6 @@ function RawPopover({ const shouldUseWebKeepMountedTransition = keepChildrenMounted && !platformEnv.isNative; const shouldAnimateContent = !keepChildrenMounted; - const keyboardHeight = useKeyboardHeight(); const zIndex = useOverlayZIndex(isOpen); const content = ( From f7e70bfd8fab26f3f0d893b324d60819bb6dd544 Mon Sep 17 00:00:00 2001 From: weatherstar Date: Tue, 8 Sep 2026 10:47:48 +0800 Subject: [PATCH 9/9] fix: register new native graph modules in module-id registry tokenDetailsNetworkAutoEnable.ts, tooltipRegistry.ts and useTooltipOpenState.ts entered the native bundle graph without registry rows, so the union build failed the startup graph budget check. --- apps/mobile/bundle-registry/module-id-registry.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/bundle-registry/module-id-registry.json b/apps/mobile/bundle-registry/module-id-registry.json index 704b5c2ecc09..4e9a7bbdc53a 100644 --- a/apps/mobile/bundle-registry/module-id-registry.json +++ b/apps/mobile/bundle-registry/module-id-registry.json @@ -16008,7 +16008,9 @@ "packages/components/src/actions/Tooltip/TooltipText.native.tsx": 9038, "packages/components/src/actions/Tooltip/context.ts": 19431, "packages/components/src/actions/Tooltip/index.native.tsx": 11950, + "packages/components/src/actions/Tooltip/tooltipRegistry.ts": 5727, "packages/components/src/actions/Tooltip/type.ts": 12369, + "packages/components/src/actions/Tooltip/useTooltipOpenState.ts": 3083, "packages/components/src/actions/Trigger/index.tsx": 8924, "packages/components/src/actions/index.ts": 9237, "packages/components/src/composite/Banner/CloseButton.tsx": 8342, @@ -20622,6 +20624,7 @@ "packages/kit/src/views/AssetDetails/pages/TokenDetails/TokenDetailsView.tsx": 14189, "packages/kit/src/views/AssetDetails/pages/TokenDetails/index.tsx": 24046, "packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsMarketNavigation.ts": 13541, + "packages/kit/src/views/AssetDetails/pages/TokenDetails/tokenDetailsNetworkAutoEnable.ts": 12837, "packages/kit/src/views/AssetDetails/pages/TokenDetails/useAggregateTokenDetails.ts": 3554, "packages/kit/src/views/AssetDetails/pages/UTXODetails.tsx": 2257, "packages/kit/src/views/AssetDetails/router/index.ts": 3427,