Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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": "^30.2.0",
"postcss": "^8.4.49",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.1.0",
Expand Down
504 changes: 504 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions src/components/SafeTextMorph.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* @jest-environment jsdom
*/
import React, { 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();

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');
});
});
61 changes: 61 additions & 0 deletions src/components/SafeTextMorph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

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

class TextMorphErrorBoundary extends React.Component<
{ children: ReactNode; fallbackText: string },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; fallbackText: string }) {
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);
}

render() {
if (this.state.hasError) {
return <span>{this.props.fallbackText}</span>;
}
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}>
<TextMorph
as={as}
className={className}
style={style}
duration={duration}
>
{textContent}
</TextMorph>
</TextMorphErrorBoundary>
);
}
5 changes: 3 additions & 2 deletions src/components/search/SearchFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { ChainSearchModal } from '../../features/chains/ChainSearchModal';
import { getChainDisplayName } from '../../features/chains/utils';
import { useMultiProvider } from '../../store';
import { SafeTextMorph } from '../SafeTextMorph';
import { Color } from '../../styles/Color';
import { MessageStatusFilter } from '../../types';
import { SolidButton } from '../buttons/SolidButton';
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,7 @@ function StatusSelector({
<Popover
button={
<>
<span>{hasValue ? currentLabel : 'Status'}</span>
<SafeTextMorph as="span">{hasValue ? currentLabel : 'Status'}</SafeTextMorph>
{!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,14 @@ 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">
<SafeTextMorph>{`${
isIcaMsg ? 'ICA ' : ''
} Message ${trimToLength(msgId, 6)} to ${getChainDisplayName(
multiProvider,
destinationChainName,
)}`}</SafeTextMorph>
</h2>
<StatusHeader
messageStatus={status}
isMessageFound={isMessageFound}
Expand Down Expand Up @@ -189,7 +192,7 @@ 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
5 changes: 3 additions & 2 deletions src/features/messages/MessageTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MessageStatus, MessageStub, WarpRouteChainAddressMap } from '../../type
import { formatAddress, formatTxHash } from '../../utils/addresses';
import { formatAmountCompact } from '../../utils/amount';
import { getHumanReadableTimeString } from '../../utils/time';
import { SafeTextMorph } from '../../components/SafeTextMorph';
import { getChainDisplayName } from '../chains/utils';
import { parseWarpRouteMessageDetails, serializeMessage } from './utils';

Expand Down Expand Up @@ -96,11 +97,11 @@ 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
2 changes: 1 addition & 1 deletion 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 Down
Loading