diff --git a/.changeset/nice-elephants-heal.md b/.changeset/nice-elephants-heal.md new file mode 100644 index 000000000..d186955c4 --- /dev/null +++ b/.changeset/nice-elephants-heal.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/utils': minor +--- + +Add an isRelativeUrl function diff --git a/.changeset/polite-kings-warn.md b/.changeset/polite-kings-warn.md new file mode 100644 index 000000000..0a32d1ca1 --- /dev/null +++ b/.changeset/polite-kings-warn.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Add a validateZodResult util function diff --git a/.changeset/shaggy-countries-kick.md b/.changeset/shaggy-countries-kick.md new file mode 100644 index 000000000..3d9a74062 --- /dev/null +++ b/.changeset/shaggy-countries-kick.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/widgets': minor +--- + +Add various utility hooks: useIsSsr, useTimeout, useDebounce, useInterval diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index 75d74e5c8..68e474d18 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -26,7 +26,6 @@ export { testCosmosChain, testSealevelChain, } from './consts/testChains.js'; -export { randomAddress } from './test/testUtils.js'; export { attachAndConnectContracts, attachContracts, @@ -329,6 +328,7 @@ export { SmartProviderOptions, } from './providers/SmartProvider/types.js'; export { CallData } from './providers/transactions/types.js'; +export { randomAddress } from './test/testUtils.js'; export { SubmitterMetadataSchema } from './providers/transactions/submitter/schemas.js'; export { TxSubmitterInterface } from './providers/transactions/submitter/TxSubmitterInterface.js'; @@ -533,7 +533,7 @@ export { isSyntheticRebaseConfig, isTokenMetadata, } from './token/schemas.js'; -export { isCompliant } from './utils/schemas.js'; +export { isCompliant, validateZodResult } from './utils/schemas.js'; export { canProposeSafeTransactions, diff --git a/typescript/sdk/src/utils/schemas.ts b/typescript/sdk/src/utils/schemas.ts index 2babea6c0..22de9ad0b 100644 --- a/typescript/sdk/src/utils/schemas.ts +++ b/typescript/sdk/src/utils/schemas.ts @@ -1,6 +1,20 @@ -import { z } from 'zod'; +import { SafeParseReturnType, z } from 'zod'; + +import { rootLogger } from '@hyperlane-xyz/utils'; export function isCompliant(schema: S) { return (config: unknown): config is z.infer => schema.safeParse(config).success; } + +export function validateZodResult( + result: SafeParseReturnType, + 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; + } +} diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index c4a869283..ff226449f 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -170,7 +170,7 @@ export { TokenCaip19Id, WithAddress, } from './types.js'; -export { isHttpsUrl, isUrl } from './url.js'; +export { isHttpsUrl, isRelativeUrl, isUrl } from './url.js'; export { assert } from './validation.js'; export { BaseValidator, ValidatorConfig } from './validator.js'; export { tryParseJsonOrYaml } from './yaml.js'; diff --git a/typescript/utils/src/url.test.ts b/typescript/utils/src/url.test.ts new file mode 100644 index 000000000..d64c8d9f5 --- /dev/null +++ b/typescript/utils/src/url.test.ts @@ -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; + }); +}); diff --git a/typescript/utils/src/url.ts b/typescript/utils/src/url.ts index e1cca81ff..98f9ee412 100644 --- a/typescript/utils/src/url.ts +++ b/typescript/utils/src/url.ts @@ -1,5 +1,6 @@ -export function isUrl(value: string) { +export function isUrl(value?: string | null) { try { + if (!value) return false; const url = new URL(value); return !!url.hostname; } catch (error) { @@ -7,11 +8,22 @@ export function isUrl(value: string) { } } -export function isHttpsUrl(value: string) { +export function isHttpsUrl(value?: string | null) { try { + if (!value) return false; const url = new URL(value); return url.protocol === 'https:'; } catch (error) { 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; + } +} diff --git a/typescript/widgets/.eslintrc b/typescript/widgets/.eslintrc index 8bc48f0d9..5cf219d56 100644 --- a/typescript/widgets/.eslintrc +++ b/typescript/widgets/.eslintrc @@ -1,12 +1,10 @@ { "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", "plugin:react/recommended", "plugin:react-hooks/recommended", "prettier" ], - "plugins": ["react", "react-hooks", "@typescript-eslint"], + "plugins": ["react", "react-hooks"], "rules": { // TODO use utils rootLogger in widgets lib "no-console": ["off"], diff --git a/typescript/widgets/src/components/ErrorBoundary.tsx b/typescript/widgets/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..a21611ec0 --- /dev/null +++ b/typescript/widgets/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+ +

Fatal Error Occurred

+
{details}
+ {this.props.supportLink} +
+
+ ); + } + return this.props.children; + } +} diff --git a/typescript/widgets/src/icons/Error.tsx b/typescript/widgets/src/icons/Error.tsx new file mode 100644 index 000000000..3a667c2a1 --- /dev/null +++ b/typescript/widgets/src/icons/Error.tsx @@ -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 ( + + + + ); +} + +export const ErrorIcon = memo(_Error); diff --git a/typescript/widgets/src/index.ts b/typescript/widgets/src/index.ts index 33eea26ef..fcc90c300 100644 --- a/typescript/widgets/src/index.ts +++ b/typescript/widgets/src/index.ts @@ -12,6 +12,7 @@ export { ColorPalette, seedToBgColor } from './color.js'; export { Button } from './components/Button.js'; export { CopyButton } from './components/CopyButton.js'; export { DatetimeField } from './components/DatetimeField.js'; +export { ErrorBoundary } from './components/ErrorBoundary.js'; export { IconButton } from './components/IconButton.js'; export { LinkButton } from './components/LinkButton.js'; export { SegmentedControl } from './components/SegmentedControl.js'; @@ -30,6 +31,7 @@ export { DiscordIcon } from './icons/Discord.js'; export { DocsIcon } from './icons/Docs.js'; export { EllipsisIcon } from './icons/Ellipsis.js'; export { EnvelopeIcon } from './icons/Envelope.js'; +export { ErrorIcon } from './icons/Error.js'; export { FilterIcon } from './icons/Filter.js'; export { FunnelIcon } from './icons/Funnel.js'; export { GearIcon } from './icons/Gear.js'; @@ -48,6 +50,7 @@ export { ShieldIcon } from './icons/Shield.js'; export { SpinnerIcon } from './icons/Spinner.js'; export { SwapIcon } from './icons/Swap.js'; export { TwitterIcon } from './icons/Twitter.js'; +export { type DefaultIconProps } from './icons/types.js'; export { UpDownArrowsIcon } from './icons/UpDownArrows.js'; export { WalletIcon } from './icons/Wallet.js'; export { WarningIcon } from './icons/Warning.js'; @@ -55,7 +58,6 @@ export { WebIcon } from './icons/Web.js'; export { WideChevronIcon } from './icons/WideChevron.js'; export { XCircleIcon } from './icons/XCircle.js'; export { XIcon } from './icons/X.js'; -export { type DefaultIconProps } from './icons/types.js'; export { DropdownMenu, type DropdownMenuProps } from './layout/DropdownMenu.js'; export { Modal, useModal, type ModalProps } from './layout/Modal.js'; export { Popover, type PopoverProps } from './layout/Popover.js'; @@ -75,4 +77,7 @@ export { tryClipboardGet, tryClipboardSet, } 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'; diff --git a/typescript/widgets/src/messages/useMessage.ts b/typescript/widgets/src/messages/useMessage.ts index 78dea5302..38471facf 100644 --- a/typescript/widgets/src/messages/useMessage.ts +++ b/typescript/widgets/src/messages/useMessage.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import { HYPERLANE_EXPLORER_API_URL } from '../consts.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'; diff --git a/typescript/widgets/src/messages/useMessageStage.ts b/typescript/widgets/src/messages/useMessageStage.ts index 54345d4a9..9351e31d4 100644 --- a/typescript/widgets/src/messages/useMessageStage.ts +++ b/typescript/widgets/src/messages/useMessageStage.ts @@ -5,7 +5,7 @@ import { fetchWithTimeout } from '@hyperlane-xyz/utils'; import { HYPERLANE_EXPLORER_API_URL } from '../consts.js'; import { queryExplorerForBlock } from '../utils/explorers.js'; -import { useInterval } from '../utils/useInterval.js'; +import { useInterval } from '../utils/timeout.js'; import { MessageStatus, diff --git a/typescript/widgets/src/stories/ErrorBoundary.stories.tsx b/typescript/widgets/src/stories/ErrorBoundary.stories.tsx new file mode 100644 index 000000000..784e84237 --- /dev/null +++ b/typescript/widgets/src/stories/ErrorBoundary.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ErrorBoundary } from '../components/ErrorBoundary'; + +function ErrorTest() { + return ( + }> + + + ); +} + +function ComponentThatThrows() { + if (React) throw new Error('Something went wrong'); + return
Hello
; +} + +function SupportLink() { + return MyLink; +} + +const meta = { + title: 'ErrorBoundary', + component: ErrorTest, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const DefaultErrorBoundary = { + args: {}, +} satisfies Story; diff --git a/typescript/widgets/src/utils/debounce.ts b/typescript/widgets/src/utils/debounce.ts new file mode 100644 index 000000000..43524d5b0 --- /dev/null +++ b/typescript/widgets/src/utils/debounce.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +// Based on https://usehooks.com/useDebounce +export function useDebounce(value: T, delayMs = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => { + clearTimeout(handler); + }; + }, [value, delayMs]); + + return debouncedValue; +} diff --git a/typescript/widgets/src/utils/ssr.ts b/typescript/widgets/src/utils/ssr.ts new file mode 100644 index 000000000..98ff02a8b --- /dev/null +++ b/typescript/widgets/src/utils/ssr.ts @@ -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; +} diff --git a/typescript/widgets/src/utils/timeout.ts b/typescript/widgets/src/utils/timeout.ts new file mode 100644 index 000000000..e87f67b37 --- /dev/null +++ b/typescript/widgets/src/utils/timeout.ts @@ -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(); + + 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; +}; diff --git a/typescript/widgets/src/utils/useInterval.ts b/typescript/widgets/src/utils/useInterval.ts deleted file mode 100644 index c8ae6d122..000000000 --- a/typescript/widgets/src/utils/useInterval.ts +++ /dev/null @@ -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]); -}