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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/bundle-registry/module-id-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<unknown>,
) => Promise<unknown>;
};
}
).__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<string, unknown>;
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;
Expand Down
35 changes: 32 additions & 3 deletions packages/components/src/actions/Popover/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,14 +52,23 @@ 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,
maxWidth: 480,
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 = {
Expand Down Expand Up @@ -269,6 +278,25 @@ 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 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) -
keyboardHeight,
)
Comment thread
weatherstar marked this conversation as resolved.
: undefined;
const triggerRef = useRef<View | null>(null);
const contentRef = useRef<View | null>(null);
const placement = getPlacement(placementProp, triggerRef);
Expand Down Expand Up @@ -365,7 +393,6 @@ function RawPopover({
const shouldUseWebKeepMountedTransition =
keepChildrenMounted && !platformEnv.isNative;
const shouldAnimateContent = !keepChildrenMounted;
const keyboardHeight = useKeyboardHeight();
const zIndex = useOverlayZIndex(isOpen);
const content = (
<ModalPortalProvider>
Expand Down Expand Up @@ -542,6 +569,7 @@ function RawPopover({
{/* header */}
{showHeader ? (
<XStack
onLayout={handleSheetHeaderLayout}
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
backgroundColor="$bg"
Expand Down Expand Up @@ -593,6 +621,7 @@ function RawPopover({
showsVerticalScrollIndicator={false}
mx="$5"
mb={bottom || '$5'}
maxHeight={sheetScrollViewMaxHeight}
borderCurve="continuous"
>
{content}
Expand Down
35 changes: 32 additions & 3 deletions packages/components/src/actions/Popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,14 +51,23 @@ 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,
maxWidth: 480,
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 = {
Expand Down Expand Up @@ -263,6 +272,25 @@ 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 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) -
keyboardHeight,
)
: undefined;
const triggerRef = useRef<View | null>(null);
const contentRef = useRef<View | null>(null);
const placement = getPlacement(placementProp, triggerRef);
Expand Down Expand Up @@ -359,7 +387,6 @@ function RawPopover({
const shouldUseWebKeepMountedTransition =
keepChildrenMounted && !platformEnv.isNative;
const shouldAnimateContent = !keepChildrenMounted;
const keyboardHeight = useKeyboardHeight();
const zIndex = useOverlayZIndex(isOpen);
const content = (
<ModalPortalProvider>
Expand Down Expand Up @@ -519,6 +546,7 @@ function RawPopover({
{/* header */}
{showHeader ? (
<XStack
onLayout={handleSheetHeaderLayout}
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
backgroundColor="$bg"
Expand Down Expand Up @@ -570,6 +598,7 @@ function RawPopover({
showsVerticalScrollIndicator={false}
mx="$5"
mb={bottom || '$5'}
maxHeight={sheetScrollViewMaxHeight}
borderCurve="continuous"
>
{content}
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/actions/Tooltip/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export function Tooltip({ renderTrigger }: ITooltipProps) {
Tooltip.Text = TooltipText;

export * from './context';
export { closeAllTooltips } from './tooltipRegistry';
export * from './type';
Loading
Loading