Skip to content
Draft
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"react-dom": "^18.3.1",
"react-toastify": "^10.0.6",
"react-tooltip": "^5.26.3",
"torph": "0.0.5",
"urql": "^3.0.3",
"yaml": "^2.4.5",
"zod": "^3.21.2",
Expand All @@ -56,6 +57,7 @@
"eslint-config-prettier": "^9.1.0",
"globals": "^16.5.0",
"jest": "^29.6.3",
"jest-environment-jsdom": "^29.6.3",
"postcss": "^8.4.49",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.1.0",
Expand Down
355 changes: 354 additions & 1 deletion pnpm-lock.yaml

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions src/components/SafeTextMorph.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @jest-environment jsdom
*/
import { act } from 'react';
import { createRoot } from 'react-dom/client';

import { SafeTextMorph } from './SafeTextMorph';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

let shouldThrow = false;

jest.mock('torph/react', () => ({
TextMorph: ({ children, as: Tag = 'span', ...props }: any) => {
if (shouldThrow) {
throw new Error('TextMorph animation error');
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ReactLib = require('react');
return ReactLib.createElement(Tag, props, children);
},
}));

let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
shouldThrow = false;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});

describe('SafeTextMorph', () => {
it('renders children string correctly', () => {
act(() => {
root.render(<SafeTextMorph>Hello World</SafeTextMorph>);
});
expect(container.textContent).toBe('Hello World');
});

it('renders fallback when TextMorph throws', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
shouldThrow = true;

act(() => {
root.render(<SafeTextMorph>Fallback Text</SafeTextMorph>);
});

expect(container.textContent).toBe('Fallback Text');
const span = container.querySelector('span');
expect(span).not.toBeNull();
expect(consoleSpy).toHaveBeenCalledWith('TextMorph error:', expect.any(Error));

consoleSpy.mockRestore();
});

it('handles empty string', () => {
act(() => {
root.render(<SafeTextMorph>{''}</SafeTextMorph>);
});
expect(container.textContent).toBe('');
});

it('coerces number to string', () => {
act(() => {
root.render(<SafeTextMorph>{42}</SafeTextMorph>);
});
expect(container.textContent).toBe('42');
});
});
77 changes: 77 additions & 0 deletions src/components/SafeTextMorph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import React, { type CSSProperties, type ReactNode } from 'react';
import { TextMorph } from 'torph/react';

type TextMorphErrorBoundaryProps = {
children: ReactNode;
fallbackText: string;
as: keyof JSX.IntrinsicElements;
className?: string;
style?: CSSProperties;
};

class TextMorphErrorBoundary extends React.Component<
TextMorphErrorBoundaryProps,
{ hasError: boolean }
> {
constructor(props: TextMorphErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: Error) {
// eslint-disable-next-line no-console
console.error('TextMorph error:', error);
}

componentDidUpdate(prevProps: TextMorphErrorBoundaryProps) {
if (
this.state.hasError &&
(prevProps.fallbackText !== this.props.fallbackText ||
prevProps.as !== this.props.as ||
prevProps.className !== this.props.className ||
prevProps.style !== this.props.style)
) {
this.setState({ hasError: false });
}
}

render() {
if (this.state.hasError) {
const { as: Tag, className, style, fallbackText } = this.props;
return React.createElement(Tag, { className, style }, fallbackText);
}
return this.props.children;
}
}

interface SafeTextMorphProps {
children: string | number | boolean | null | undefined;
as?: keyof JSX.IntrinsicElements;
className?: string;
style?: CSSProperties;
duration?: number;
}

export function SafeTextMorph({
children,
as = 'span',
className,
style,
duration,
}: SafeTextMorphProps) {
const textContent = String(children ?? '');

return (
<TextMorphErrorBoundary fallbackText={textContent} as={as} className={className} style={style}>
<TextMorph as={as} className={className} style={style} duration={duration}>
{textContent}
</TextMorph>
</TextMorphErrorBoundary>
);
}
9 changes: 7 additions & 2 deletions src/components/search/SearchFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Color } from '../../styles/Color';
import { MessageStatusFilter } from '../../types';
import { SolidButton } from '../buttons/SolidButton';
import { TextButton } from '../buttons/TextButton';
import { SafeTextMorph } from '../SafeTextMorph';

interface Props {
originChain: string | null;
Expand Down Expand Up @@ -100,7 +101,7 @@ function ChainSelector({
)}
onClick={open}
>
<span>{chainDisplayName || text} </span>
<SafeTextMorph as="span">{chainDisplayName || text}</SafeTextMorph>
{!value && (
<ChevronIcon
direction="s"
Expand Down Expand Up @@ -235,7 +236,11 @@ function StatusSelector({
<Popover
button={
<>
<span>{hasValue ? currentLabel : 'Status'}</span>
{hasValue ? (
<SafeTextMorph as="span">{currentLabel}</SafeTextMorph>
) : (
<span>Status</span>
)}
{!hasValue && (
<ChevronIcon
direction="s"
Expand Down
17 changes: 10 additions & 7 deletions src/features/messages/MessageDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Image from 'next/image';
import { useEffect, useMemo } from 'react';
import { toast } from 'react-toastify';
import { Card } from '../../components/layout/Card';
import { SafeTextMorph } from '../../components/SafeTextMorph';
import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
import { useMultiProvider, useStore } from '../../store';
import { Message, MessageStatus } from '../../types';
Expand Down Expand Up @@ -99,12 +100,12 @@ export function MessageDetails({ messageId, message: messageFromUrlParams }: Pro
return (
<>
<Card className="flex items-center justify-between rounded-full px-1">
<h2 className="font-medium text-blue-500">{`${
isIcaMsg ? 'ICA ' : ''
} Message ${trimToLength(msgId, 6)} to ${getChainDisplayName(
multiProvider,
destinationChainName,
)}`}</h2>
<h2 className="font-medium text-blue-500">
{`${isIcaMsg ? 'ICA ' : ''} Message ${trimToLength(msgId, 6)} to ${getChainDisplayName(
multiProvider,
destinationChainName,
)}`}
</h2>
<StatusHeader
messageStatus={status}
isMessageFound={isMessageFound}
Expand Down Expand Up @@ -189,7 +190,9 @@ function StatusHeader({

return (
<div className="flex items-center">
<h3 className="lg mr-3 font-medium text-blue-500">{text}</h3>
<h3 className="lg mr-3 font-medium text-blue-500">
<SafeTextMorph>{text}</SafeTextMorph>
</h3>
{icon}
</div>
);
Expand Down
17 changes: 12 additions & 5 deletions src/features/messages/MessageTable.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { MultiProtocolProvider } from '@hyperlane-xyz/sdk';
import { shortenAddress } from '@hyperlane-xyz/utils';
import clsx from 'clsx';
import Image from 'next/image';
import Link from 'next/link';
import { PropsWithChildren, useMemo } from 'react';
import { ChainLogo } from '../../components/icons/ChainLogo';
import { TokenIcon } from '../../components/icons/TokenIcon';
import { SafeTextMorph } from '../../components/SafeTextMorph';
import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
import ErrorIcon from '../../images/icons/error-circle.svg';
import { useMultiProvider, useStore } from '../../store';
Expand Down Expand Up @@ -42,9 +44,10 @@ export function MessageTable({
{messageList.map((m) => (
<tr
key={`message-${m.id}`}
className={`relative cursor-pointer border-b border-blue-50 last:border-0 hover:bg-pink-50 active:bg-pink-100 ${
isFetching && 'blur-xs'
} transition-all duration-500`}
className={clsx(
'relative cursor-pointer border-b border-blue-50 transition-all duration-500 last:border-0 hover:bg-pink-50 active:bg-pink-100',
{ 'blur-xs': isFetching },
)}
>
<MessageSummaryRow
message={m}
Expand Down Expand Up @@ -96,11 +99,15 @@ export function MessageSummaryRow({
<>
<LinkCell id={msgId} base64={base64} aClasses="flex items-center py-3.5 pl-3 sm:pl-5">
<ChainLogo chainName={originChainName} size={20} />
<div className={styles.iconText}>{getChainDisplayName(mp, originChainName, true)}</div>
<div className={styles.iconText}>
<SafeTextMorph>{getChainDisplayName(mp, originChainName, true)}</SafeTextMorph>
</div>
</LinkCell>
<LinkCell id={msgId} base64={base64} aClasses="flex items-center py-3.5">
<ChainLogo chainName={destinationChainName} size={20} />
<div className={styles.iconText}>{getChainDisplayName(mp, destinationChainName, true)}</div>
<div className={styles.iconText}>
<SafeTextMorph>{getChainDisplayName(mp, destinationChainName, true)}</SafeTextMorph>
</div>
</LinkCell>
<LinkCell id={msgId} base64={base64} tdClasses="hidden sm:table-cell" aClasses={styles.value}>
{shortenAddress(formattedSender) || 'Invalid Address'}
Expand Down
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"noEmit": true,
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
Expand All @@ -29,7 +29,8 @@
"include": ["next-env.d.ts", "./src/"],
"ts-node": {
"compilerOptions": {
"module": "commonjs"
"module": "commonjs",
"moduleResolution": "node"
}
}
}