pl-fe: error handling improvements, some refactoring i forgot to commit before

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-01-07 22:08:15 +01:00
parent 83a909fdc2
commit 6929bd5849
8 changed files with 46 additions and 308 deletions

View File

@ -1,37 +0,0 @@
import { defineMessages } from 'react-intl';
import toast from 'pl-fe/toast';
import { getClient } from '../api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const messages = defineMessages({
blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' },
followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' },
mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' },
});
const importFollows = (list: File | string, overwrite?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.importFollows(list, overwrite ? 'overwrite' : 'merge').then(response => {
toast.success(messages.followersSuccess);
});
const importBlocks = (list: File | string, overwrite?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.importBlocks(list, overwrite ? 'overwrite' : 'merge').then(response => {
toast.success(messages.blocksSuccess);
});
const importMutes = (list: File | string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.importMutes(list).then(response => {
toast.success(messages.mutesSuccess);
});
export {
importFollows,
importBlocks,
importMutes,
};

View File

@ -1,204 +0,0 @@
import React, { type ErrorInfo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { FormattedMessage } from 'react-intl';
import { NODE_ENV } from 'pl-fe/build-config';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import Textarea from 'pl-fe/components/ui/textarea';
import { useLogo } from 'pl-fe/hooks/use-logo';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { captureSentryException } from 'pl-fe/sentry';
import KVStore from 'pl-fe/storage/kv-store';
import sourceCode from 'pl-fe/utils/code';
import { unregisterSW } from 'pl-fe/utils/sw';
import SentryFeedbackForm from './sentry-feedback-form';
import SiteLogo from './site-logo';
interface ISiteErrorBoundary {
children: React.ReactNode;
}
/** Application-level error boundary. Fills the whole screen. */
const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
const { links, sentryDsn } = usePlFeConfig();
const { src: logoSrc } = useLogo();
const textarea = useRef<HTMLTextAreaElement>(null);
const [error, setError] = useState<unknown>();
const [componentStack, setComponentStack] = useState<string | null | undefined>();
const [browser, setBrowser] = useState<Bowser.Parser.Parser>();
const [sentryEventId, setSentryEventId] = useState<string>();
const sentryEnabled = Boolean(sentryDsn);
const isProduction = NODE_ENV === 'production';
const errorText = String(error) + componentStack;
const clearCookies: React.MouseEventHandler = (e) => {
localStorage.clear();
sessionStorage.clear();
KVStore.clear();
if ('serviceWorker' in navigator) {
e.preventDefault();
unregisterSW().then(goHome).catch(goHome);
}
};
const handleCopy: React.MouseEventHandler = () => {
if (!textarea.current) return;
textarea.current.select();
textarea.current.setSelectionRange(0, 99999);
document.execCommand('copy');
};
const handleError = (error: Error, info: ErrorInfo) => {
setError(error);
setComponentStack(info.componentStack);
captureSentryException(error, {
tags: {
// Allow page crashes to be easily searched in Sentry.
ErrorBoundary: 'yes',
},
})
.then((eventId) => setSentryEventId(eventId))
.catch(console.error);
import('bowser')
.then(({ default: Bowser }) => setBrowser(Bowser.getParser(window.navigator.userAgent)))
.catch(() => {});
};
const goHome = () => {
location.href = '/';
};
const fallback = (
<div className='flex h-screen flex-col bg-white pb-12 pt-16 black:bg-black dark:bg-primary-900'>
<main className='mx-auto flex w-full max-w-7xl grow flex-col justify-center px-4 sm:px-6 lg:px-8'>
{logoSrc && (
<div className='flex shrink-0 justify-center'>
<a href='/' className='inline-flex'>
<SiteLogo className='h-12 w-auto cursor-pointer' />
</a>
</div>
)}
<div className='py-8'>
<div className='mx-auto max-w-xl space-y-2 text-center'>
<h1 className='text-3xl font-extrabold tracking-tight text-gray-900 dark:text-gray-500 sm:text-4xl'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1>
<p className='text-lg text-gray-700 dark:text-gray-600'>
<FormattedMessage
id='alert.unexpected.body'
defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)."
values={{
clearCookies: (
<a href='/' onClick={clearCookies} className='text-primary-600 hover:underline dark:text-primary-400'>
<FormattedMessage
id='alert.unexpected.clear_cookies'
defaultMessage='clear cookies and browser data'
/>
</a>
),
}}
/>
</p>
<Text theme='muted'>
<Text weight='medium' tag='span' theme='muted'>{sourceCode.displayName}:</Text>
{' '}{sourceCode.version}
</Text>
<div className='mt-10'>
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-primary-400'>
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
{' '}
<span className='inline-block rtl:rotate-180' aria-hidden='true'>&rarr;</span>
</a>
</div>
</div>
<div className='mx-auto max-w-lg space-y-4 py-16'>
{(isProduction) ? (
(sentryEnabled && sentryEventId) && (
<SentryFeedbackForm eventId={sentryEventId} />
)
) : (
<>
{errorText && (
<Textarea
ref={textarea}
value={errorText}
onClick={handleCopy}
isCodeEditor
rows={12}
readOnly
/>
)}
{browser && (
<Stack>
<Text weight='semibold'><FormattedMessage id='alert.unexpected.browser' defaultMessage='Browser' /></Text>
<Text theme='muted'>{browser.getBrowserName()} {browser.getBrowserVersion()}</Text>
</Stack>
)}
</>
)}
</div>
</div>
</main>
<footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'>
<HStack justifyContent='center' space={4} element='nav'>
{links.status && (
<SiteErrorBoundaryLink href={links.status}>
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
</SiteErrorBoundaryLink>
)}
{links.help && (
<SiteErrorBoundaryLink href={links.help}>
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
</SiteErrorBoundaryLink>
)}
{links.support && (
<SiteErrorBoundaryLink href={links.support}>
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
</SiteErrorBoundaryLink>
)}
</HStack>
</footer>
</div>
);
return (
<ErrorBoundary fallback={fallback} onError={handleError}>
{children}
</ErrorBoundary>
);
};
interface ISiteErrorBoundaryLink {
href: string;
children: React.ReactNode;
}
const SiteErrorBoundaryLink = ({ href, children }: ISiteErrorBoundaryLink) => (
<>
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={href} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
{children}
</a>
</>
);
export { SiteErrorBoundary as default };

View File

@ -1,45 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Column from 'pl-fe/components/ui/column';
import IconButton from 'pl-fe/components/ui/icon-button';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { isNetworkError } from 'pl-fe/utils/errors';
import type { ErrorRouteComponent } from '@tanstack/react-router';
const messages = defineMessages({
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this page.' },
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
});
const ErrorColumn: ErrorRouteComponent = ({ error, reset }) => {
const intl = useIntl();
const handleRetry = () => {
reset();
};
if (!isNetworkError(error)) {
throw error;
}
return (
<Column label={intl.formatMessage(messages.title)}>
<Stack space={4} alignItems='center' justifyContent='center' className='min-h-[160px] rounded-lg p-10'>
<IconButton
iconClassName='h-10 w-10'
title={intl.formatMessage(messages.retry)}
src={require('@phosphor-icons/core/regular/arrows-clockwise.svg')}
onClick={handleRetry}
/>
<Text align='center' theme='muted'>{intl.formatMessage(messages.body)}</Text>
</Stack>
</Column>
);
};
export { ErrorColumn as default };

View File

@ -12,6 +12,7 @@ import React, { useMemo } from 'react';
import * as v from 'valibot';
import { FE_SUBDIRECTORY, WITH_LANDING_PAGE } from 'pl-fe/build-config';
import SiteError from 'pl-fe/components/site-error';
import Layout from 'pl-fe/components/ui/layout';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
@ -147,8 +148,6 @@ import {
AwaitingApproval,
} from '../util/async-components';
import ErrorColumn from './error-column';
import type { Features } from 'pl-api';
interface RouterContext {
@ -1432,7 +1431,7 @@ const router = createRouter({
},
defaultNotFoundComponent: GenericNotFound,
defaultPendingComponent: PendingComponent,
defaultErrorComponent: ErrorColumn,
defaultErrorComponent: SiteError,
scrollRestoration: true,
pathParamsAllowedCharacters: ['@'],
});

View File

@ -1,18 +1,15 @@
import React, { Suspense } from 'react';
import LoadingScreen from 'pl-fe/components/loading-screen';
import SiteErrorBoundary from 'pl-fe/components/site-error-boundary';
import { RouterWithContext } from 'pl-fe/features/ui/router';
/** Highest level node with the Redux store. */
const PlFeMount = () => {
return (
<SiteErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<RouterWithContext />
</Suspense>
</SiteErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<RouterWithContext />
</Suspense>
);
};

View File

@ -233,14 +233,14 @@
"admin.users.user_unsuggested_message": "@{acct} was unsuggested",
"admin.users.user_unverified_message": "@{acct} was unverified",
"admin.users.user_verified_message": "@{acct} was verified",
"alert.unexpected.body": "We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out).",
"alert.unexpected.body": "We're sorry for the interruption. If the problem persists, please report it in our {issueTracker}. You may also try to {clearCookies} (this will log you out).",
"alert.unexpected.browser": "Browser",
"alert.unexpected.clear_cookies": "clear cookies and browser data",
"alert.unexpected.issue_tracker": "issue tracker",
"alert.unexpected.links.help": "Help Center",
"alert.unexpected.links.status": "Status",
"alert.unexpected.links.support": "Support",
"alert.unexpected.message": "Something went wrong.",
"alert.unexpected.return_home": "Return Home",
"alert.unexpected.submit_feedback": "Submit Feedback",
"alert.unexpected.thanks": "Thanks for your feedback!",
"aliases.account.add": "Create alias",

View File

@ -1,11 +1,6 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl, type MessageDescriptor } from 'react-intl';
import {
importFollows,
importBlocks,
importMutes,
} from 'pl-fe/actions/import-data';
import List, { ListItem } from 'pl-fe/components/list';
import Button from 'pl-fe/components/ui/button';
import Column from 'pl-fe/components/ui/column';
@ -15,14 +10,16 @@ import FormActions from 'pl-fe/components/ui/form-actions';
import FormGroup from 'pl-fe/components/ui/form-group';
import Text from 'pl-fe/components/ui/text';
import Toggle from 'pl-fe/components/ui/toggle';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import type { AppDispatch, RootState } from 'pl-fe/store';
import toast from 'pl-fe/toast';
const messages = defineMessages({
heading: { id: 'column.import_data', defaultMessage: 'Import data' },
submit: { id: 'import_data.actions.import', defaultMessage: 'Import' },
blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' },
followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' },
mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' },
});
const followMessages = defineMessages({
@ -49,13 +46,12 @@ interface IDataImporter {
input_hint: MessageDescriptor;
submit: MessageDescriptor;
};
action: (list: File, overwrite?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => Promise<void>;
action: (list: File, overwrite?: boolean) => Promise<void>;
accept?: string;
allowOverwrite?: boolean;
}
const DataImporter: React.FC<IDataImporter> = ({ messages, action, accept = '.csv,text/csv', allowOverwrite }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [isLoading, setIsLoading] = useState(false);
@ -64,7 +60,7 @@ const DataImporter: React.FC<IDataImporter> = ({ messages, action, accept = '.cs
const handleSubmit: React.FormEventHandler = (event) => {
setIsLoading(true);
dispatch(action(file!, overwrite)).then(() => {
action(file!, overwrite).then(() => {
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
@ -114,9 +110,25 @@ const DataImporter: React.FC<IDataImporter> = ({ messages, action, accept = '.cs
};
const ImportDataPage = () => {
const client = useClient();
const intl = useIntl();
const features = useFeatures();
const importFollows = (list: File | string, overwrite?: boolean) =>
client.settings.importFollows(list, overwrite ? 'overwrite' : 'merge').then(response => {
toast.success(messages.followersSuccess);
});
const importBlocks = (list: File | string, overwrite?: boolean) =>
client.settings.importBlocks(list, overwrite ? 'overwrite' : 'merge').then(response => {
toast.success(messages.blocksSuccess);
});
const importMutes = (list: File | string) =>
client.settings.importMutes(list).then(response => {
toast.success(messages.mutesSuccess);
});
return (
<Column label={intl.formatMessage(messages.heading)}>
{features.importFollows && <DataImporter action={importFollows} messages={followMessages} allowOverwrite={features.importOverwrite} />}

View File

@ -422,4 +422,20 @@ div:has(.⁂-background-shapes), .dark {
@include mixins.text($size: sm, $theme: muted);
content: '·';
user-select: none;
}
.-site-error {
@apply flex h-screen flex-col bg-white pb-12 pt-16 black:bg-black dark:bg-primary-900 md:col-span-12 lg:col-span-9 xl:col-span-6;
main {
@apply mx-auto flex w-full max-w-7xl grow flex-col justify-center px-4 sm:px-6 lg:px-8;
}
footer {
@apply mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8;
}
}
.-layout__content .-site-error {
height: fit-content;
}