chore: Migrate various utils from the Warp UI to the Widgets lib (#4856)

### Description

More ground-work for the upcoming Warp Deploy app
No new code, just migrating things from the Warp UI

### Backward compatibility

Yes

### Testing

Tested in Warp UI
pull/4877/head
J M Rossy 2 weeks ago committed by GitHub
parent 6f2d50fbd6
commit 0e285a4433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/nice-elephants-heal.md
  2. 5
      .changeset/polite-kings-warn.md
  3. 5
      .changeset/shaggy-countries-kick.md
  4. 4
      typescript/sdk/src/index.ts
  5. 16
      typescript/sdk/src/utils/schemas.ts
  6. 2
      typescript/utils/src/index.ts
  7. 32
      typescript/utils/src/url.test.ts
  8. 16
      typescript/utils/src/url.ts
  9. 4
      typescript/widgets/.eslintrc
  10. 47
      typescript/widgets/src/components/ErrorBoundary.tsx
  11. 18
      typescript/widgets/src/icons/Error.tsx
  12. 7
      typescript/widgets/src/index.ts
  13. 2
      typescript/widgets/src/messages/useMessage.ts
  14. 2
      typescript/widgets/src/messages/useMessageStage.ts
  15. 32
      typescript/widgets/src/stories/ErrorBoundary.stories.tsx
  16. 18
      typescript/widgets/src/utils/debounce.ts
  17. 10
      typescript/widgets/src/utils/ssr.ts
  18. 52
      typescript/widgets/src/utils/timeout.ts
  19. 27
      typescript/widgets/src/utils/useInterval.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/utils': minor
---
Add an isRelativeUrl function

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Add a validateZodResult util function

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/widgets': minor
---
Add various utility hooks: useIsSsr, useTimeout, useDebounce, useInterval

@ -26,7 +26,6 @@ export {
testCosmosChain, testCosmosChain,
testSealevelChain, testSealevelChain,
} from './consts/testChains.js'; } from './consts/testChains.js';
export { randomAddress } from './test/testUtils.js';
export { export {
attachAndConnectContracts, attachAndConnectContracts,
attachContracts, attachContracts,
@ -329,6 +328,7 @@ export {
SmartProviderOptions, SmartProviderOptions,
} from './providers/SmartProvider/types.js'; } from './providers/SmartProvider/types.js';
export { CallData } from './providers/transactions/types.js'; export { CallData } from './providers/transactions/types.js';
export { randomAddress } from './test/testUtils.js';
export { SubmitterMetadataSchema } from './providers/transactions/submitter/schemas.js'; export { SubmitterMetadataSchema } from './providers/transactions/submitter/schemas.js';
export { TxSubmitterInterface } from './providers/transactions/submitter/TxSubmitterInterface.js'; export { TxSubmitterInterface } from './providers/transactions/submitter/TxSubmitterInterface.js';
@ -533,7 +533,7 @@ export {
isSyntheticRebaseConfig, isSyntheticRebaseConfig,
isTokenMetadata, isTokenMetadata,
} from './token/schemas.js'; } from './token/schemas.js';
export { isCompliant } from './utils/schemas.js'; export { isCompliant, validateZodResult } from './utils/schemas.js';
export { export {
canProposeSafeTransactions, canProposeSafeTransactions,

@ -1,6 +1,20 @@
import { z } from 'zod'; import { SafeParseReturnType, z } from 'zod';
import { rootLogger } from '@hyperlane-xyz/utils';
export function isCompliant<S extends Zod.Schema>(schema: S) { export function isCompliant<S extends Zod.Schema>(schema: S) {
return (config: unknown): config is z.infer<S> => return (config: unknown): config is z.infer<S> =>
schema.safeParse(config).success; schema.safeParse(config).success;
} }
export function validateZodResult<T>(
result: SafeParseReturnType<T, T>,
desc: string = 'config',
): T {
if (!result.success) {
rootLogger.warn(`Invalid ${desc}`, result.error);
throw new Error(`Invalid desc: ${result.error.toString()}`);
} else {
return result.data;
}
}

@ -170,7 +170,7 @@ export {
TokenCaip19Id, TokenCaip19Id,
WithAddress, WithAddress,
} from './types.js'; } from './types.js';
export { isHttpsUrl, isUrl } from './url.js'; export { isHttpsUrl, isRelativeUrl, isUrl } from './url.js';
export { assert } from './validation.js'; export { assert } from './validation.js';
export { BaseValidator, ValidatorConfig } from './validator.js'; export { BaseValidator, ValidatorConfig } from './validator.js';
export { tryParseJsonOrYaml } from './yaml.js'; export { tryParseJsonOrYaml } from './yaml.js';

@ -0,0 +1,32 @@
import { expect } from 'chai';
import { isHttpsUrl, isRelativeUrl, isUrl } from './url.js';
describe('URL Utilities', () => {
it('isUrl', () => {
expect(isUrl(undefined)).to.be.false;
expect(isUrl(null)).to.be.false;
expect(isUrl('')).to.be.false;
expect(isUrl('foobar')).to.be.false;
expect(isUrl('https://hyperlane.xyz')).to.be.true;
});
it('isHttpsUrl', () => {
expect(isHttpsUrl(undefined)).to.be.false;
expect(isHttpsUrl(null)).to.be.false;
expect(isHttpsUrl('')).to.be.false;
expect(isHttpsUrl('foobar')).to.be.false;
expect(isHttpsUrl('http://hyperlane.xyz')).to.be.false;
expect(isHttpsUrl('https://hyperlane.xyz')).to.be.true;
});
it('isRelativeUrl', () => {
expect(isRelativeUrl(undefined)).to.be.false;
expect(isRelativeUrl(null)).to.be.false;
expect(isRelativeUrl('')).to.be.false;
expect(isRelativeUrl('foobar')).to.be.false;
expect(isRelativeUrl('https://hyperlane.xyz')).to.be.false;
expect(isRelativeUrl('/foobar')).to.be.true;
expect(isRelativeUrl('/foo/bar', 'https://hyperlane.xyz')).to.be.true;
});
});

@ -1,5 +1,6 @@
export function isUrl(value: string) { export function isUrl(value?: string | null) {
try { try {
if (!value) return false;
const url = new URL(value); const url = new URL(value);
return !!url.hostname; return !!url.hostname;
} catch (error) { } catch (error) {
@ -7,11 +8,22 @@ export function isUrl(value: string) {
} }
} }
export function isHttpsUrl(value: string) { export function isHttpsUrl(value?: string | null) {
try { try {
if (!value) return false;
const url = new URL(value); const url = new URL(value);
return url.protocol === 'https:'; return url.protocol === 'https:';
} catch (error) { } catch (error) {
return false; return false;
} }
} }
export function isRelativeUrl(value?: string | null, base?: string): boolean {
try {
if (!value || !value.startsWith('/')) return false;
const url = new URL(value, base || 'https://hyperlane.xyz');
return !!url.pathname;
} catch {
return false;
}
}

@ -1,12 +1,10 @@
{ {
"extends": [ "extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier" "prettier"
], ],
"plugins": ["react", "react-hooks", "@typescript-eslint"], "plugins": ["react", "react-hooks"],
"rules": { "rules": {
// TODO use utils rootLogger in widgets lib // TODO use utils rootLogger in widgets lib
"no-console": ["off"], "no-console": ["off"],

@ -0,0 +1,47 @@
import React, { Component, PropsWithChildren, ReactNode } from 'react';
import { errorToString } from '@hyperlane-xyz/utils';
import { ErrorIcon } from '../icons/Error.js';
type Props = PropsWithChildren<{
supportLink?: ReactNode;
}>;
interface State {
error: any;
errorInfo: any;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null, errorInfo: null };
}
componentDidCatch(error: any, errorInfo: any) {
this.setState({
error,
errorInfo,
});
console.error('Error caught by error boundary', error, errorInfo);
}
render() {
const errorInfo = this.state.error || this.state.errorInfo;
if (errorInfo) {
const details = errorToString(errorInfo, 1000);
return (
<div className="htw-flex htw-h-screen htw-w-screen htw-items-center htw-justify-center htw-bg-gray-50">
<div className="htw-flex htw-flex-col htw-items-center htw-space-y-5">
<ErrorIcon width={80} height={80} />
<h1 className="htw-text-lg">Fatal Error Occurred</h1>
<div className="htw-max-w-2xl htw-text-sm">{details}</div>
{this.props.supportLink}
</div>
</div>
);
}
return this.props.children;
}
}

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _Error({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...rest}>
<path
fill={color || ColorPalette.Black}
d="M24 34q.7 0 1.18-.47.47-.48.47-1.18t-.47-1.18q-.48-.47-1.18-.47t-1.18.47q-.47.48-.47 1.18t.47 1.18Q23.3 34 24 34Zm.15-7.65q.65 0 1.07-.42.43-.43.43-1.08V15.2q0-.65-.42-1.07-.43-.43-1.08-.43-.65 0-1.07.42-.43.43-.43 1.08v9.65q0 .65.42 1.07.43.43 1.08.43ZM24 44q-4.1 0-7.75-1.57-3.65-1.58-6.38-4.3-2.72-2.73-4.3-6.38Q4 28.1 4 23.95q0-4.1 1.57-7.75 1.58-3.65 4.3-6.35 2.73-2.7 6.38-4.28Q19.9 4 24.05 4q4.1 0 7.75 1.57 3.65 1.58 6.35 4.28 2.7 2.7 4.28 6.35Q44 19.85 44 24q0 4.1-1.57 7.75-1.58 3.65-4.28 6.38t-6.35 4.3Q28.15 44 24 44Zm.05-3q7.05 0 12-4.97T41 23.95q0-7.05-4.95-12T24 7q-7.05 0-12.03 4.95Q7 16.9 7 24q0 7.05 4.97 12.03Q16.95 41 24.05 41ZM24 24Z"
/>
</svg>
);
}
export const ErrorIcon = memo(_Error);

@ -12,6 +12,7 @@ export { ColorPalette, seedToBgColor } from './color.js';
export { Button } from './components/Button.js'; export { Button } from './components/Button.js';
export { CopyButton } from './components/CopyButton.js'; export { CopyButton } from './components/CopyButton.js';
export { DatetimeField } from './components/DatetimeField.js'; export { DatetimeField } from './components/DatetimeField.js';
export { ErrorBoundary } from './components/ErrorBoundary.js';
export { IconButton } from './components/IconButton.js'; export { IconButton } from './components/IconButton.js';
export { LinkButton } from './components/LinkButton.js'; export { LinkButton } from './components/LinkButton.js';
export { SegmentedControl } from './components/SegmentedControl.js'; export { SegmentedControl } from './components/SegmentedControl.js';
@ -30,6 +31,7 @@ export { DiscordIcon } from './icons/Discord.js';
export { DocsIcon } from './icons/Docs.js'; export { DocsIcon } from './icons/Docs.js';
export { EllipsisIcon } from './icons/Ellipsis.js'; export { EllipsisIcon } from './icons/Ellipsis.js';
export { EnvelopeIcon } from './icons/Envelope.js'; export { EnvelopeIcon } from './icons/Envelope.js';
export { ErrorIcon } from './icons/Error.js';
export { FilterIcon } from './icons/Filter.js'; export { FilterIcon } from './icons/Filter.js';
export { FunnelIcon } from './icons/Funnel.js'; export { FunnelIcon } from './icons/Funnel.js';
export { GearIcon } from './icons/Gear.js'; export { GearIcon } from './icons/Gear.js';
@ -48,6 +50,7 @@ export { ShieldIcon } from './icons/Shield.js';
export { SpinnerIcon } from './icons/Spinner.js'; export { SpinnerIcon } from './icons/Spinner.js';
export { SwapIcon } from './icons/Swap.js'; export { SwapIcon } from './icons/Swap.js';
export { TwitterIcon } from './icons/Twitter.js'; export { TwitterIcon } from './icons/Twitter.js';
export { type DefaultIconProps } from './icons/types.js';
export { UpDownArrowsIcon } from './icons/UpDownArrows.js'; export { UpDownArrowsIcon } from './icons/UpDownArrows.js';
export { WalletIcon } from './icons/Wallet.js'; export { WalletIcon } from './icons/Wallet.js';
export { WarningIcon } from './icons/Warning.js'; export { WarningIcon } from './icons/Warning.js';
@ -55,7 +58,6 @@ export { WebIcon } from './icons/Web.js';
export { WideChevronIcon } from './icons/WideChevron.js'; export { WideChevronIcon } from './icons/WideChevron.js';
export { XCircleIcon } from './icons/XCircle.js'; export { XCircleIcon } from './icons/XCircle.js';
export { XIcon } from './icons/X.js'; export { XIcon } from './icons/X.js';
export { type DefaultIconProps } from './icons/types.js';
export { DropdownMenu, type DropdownMenuProps } from './layout/DropdownMenu.js'; export { DropdownMenu, type DropdownMenuProps } from './layout/DropdownMenu.js';
export { Modal, useModal, type ModalProps } from './layout/Modal.js'; export { Modal, useModal, type ModalProps } from './layout/Modal.js';
export { Popover, type PopoverProps } from './layout/Popover.js'; export { Popover, type PopoverProps } from './layout/Popover.js';
@ -75,4 +77,7 @@ export {
tryClipboardGet, tryClipboardGet,
tryClipboardSet, tryClipboardSet,
} from './utils/clipboard.js'; } from './utils/clipboard.js';
export { useDebounce } from './utils/debounce.js';
export { useIsSsr } from './utils/ssr.js';
export { useInterval, useTimeout } from './utils/timeout.js';
export { useConnectionHealthTest } from './utils/useChainConnectionTest.js'; export { useConnectionHealthTest } from './utils/useChainConnectionTest.js';

@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js'; import { HYPERLANE_EXPLORER_API_URL } from '../consts.js';
import { executeExplorerQuery } from '../utils/explorers.js'; import { executeExplorerQuery } from '../utils/explorers.js';
import { useInterval } from '../utils/useInterval.js'; import { useInterval } from '../utils/timeout.js';
import { ApiMessage, MessageStatus } from './types.js'; import { ApiMessage, MessageStatus } from './types.js';

@ -5,7 +5,7 @@ import { fetchWithTimeout } from '@hyperlane-xyz/utils';
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js'; import { HYPERLANE_EXPLORER_API_URL } from '../consts.js';
import { queryExplorerForBlock } from '../utils/explorers.js'; import { queryExplorerForBlock } from '../utils/explorers.js';
import { useInterval } from '../utils/useInterval.js'; import { useInterval } from '../utils/timeout.js';
import { import {
MessageStatus, MessageStatus,

@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { ErrorBoundary } from '../components/ErrorBoundary';
function ErrorTest() {
return (
<ErrorBoundary supportLink={<SupportLink />}>
<ComponentThatThrows />
</ErrorBoundary>
);
}
function ComponentThatThrows() {
if (React) throw new Error('Something went wrong');
return <div>Hello</div>;
}
function SupportLink() {
return <a>MyLink</a>;
}
const meta = {
title: 'ErrorBoundary',
component: ErrorTest,
} satisfies Meta<typeof ErrorTest>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultErrorBoundary = {
args: {},
} satisfies Story;

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
// Based on https://usehooks.com/useDebounce
export function useDebounce<T>(value: T, delayMs = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(handler);
};
}, [value, delayMs]);
return debouncedValue;
}

@ -0,0 +1,10 @@
import { useEffect, useState } from 'react';
export function useIsSsr() {
const [isSsr, setIsSsr] = useState(true);
// Effects are only run on the client side
useEffect(() => {
setIsSsr(false);
}, []);
return isSsr;
}

@ -0,0 +1,52 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// https://usehooks-typescript.com/react-hook/use-interval
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Remember the latest callback if it changes.
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
// Note: 0 is a valid value for delay.
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// https://medium.com/javascript-in-plain-english/usetimeout-react-hook-3cc58b94af1f
export const useTimeout = (
callback: () => void,
delay = 0, // in ms (default: immediately put into JS Event Queue)
): (() => void) => {
const timeoutIdRef = useRef<NodeJS.Timeout>();
const cancel = useCallback(() => {
const timeoutId = timeoutIdRef.current;
if (timeoutId) {
timeoutIdRef.current = undefined;
clearTimeout(timeoutId);
}
}, [timeoutIdRef]);
useEffect(() => {
if (delay >= 0) {
timeoutIdRef.current = setTimeout(callback, delay);
}
return cancel;
}, [callback, delay, cancel]);
return cancel;
};

@ -1,27 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// https://usehooks-typescript.com/react-hook/use-interval
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Remember the latest callback if it changes.
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
// Note: 0 is a valid value for delay.
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
Loading…
Cancel
Save