отображение ошибки
Tests / frontend-test (push) Successful in 1m9s

This commit is contained in:
gorbenko.pavel
2026-08-11 12:51:26 +05:00
parent 00977dcea6
commit 5cbc9eb89b
4 changed files with 239 additions and 39 deletions
@@ -83,3 +83,73 @@
border-color: $gold-400;
}
}
// Уведомление об АСИНХРОННОЙ ошибке: в углу, поверх содержимого, но ничего не
// перекрывает и не блокирует — портал остаётся рабочим (см. ErrorOverlay.tsx о
// том, почему такие ошибки больше не схлопывают вебпарт).
.toast {
position: fixed;
right: $sp-4;
bottom: $sp-4;
z-index: 999998;
box-sizing: border-box;
width: 360px;
max-width: calc(100vw - #{$sp-4} * 2);
padding: $sp-3 $sp-4;
border-radius: $r-md;
border-left: 4px solid $red-500;
background: $white;
box-shadow: $sh-modal;
font-family: $font-sans;
}
.toastHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.toastTitle {
font-size: $fs-sm;
font-weight: 600;
color: $navy-950;
}
.toastClose {
padding: 0 4px;
border: 0;
background: none;
color: $gray-500;
font-size: 18px;
line-height: 1;
cursor: pointer;
&:hover {
color: $navy-800;
}
}
.toastText {
margin-top: $sp-1;
max-height: 96px;
overflow: auto;
font-size: $fs-caption;
color: $gray-900;
word-break: break-word;
}
.toastCopy {
margin-top: $sp-2;
padding: 4px 10px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-size: $fs-caption;
cursor: pointer;
&:hover {
border-color: $gold-400;
}
}
@@ -1,4 +1,5 @@
import * as React from 'react';
import { describeReason } from '../../diagnostics';
import styles from './ErrorOverlay.module.scss';
interface ICaughtError {
@@ -8,12 +9,30 @@ interface ICaughtError {
}
interface IErrorOverlayState {
error: ICaughtError | undefined;
/** Падение самого рендера — дерево сломано, показывать нечего. */
fatal: ICaughtError | undefined;
/** Асинхронная ошибка со страницы — портал работает, это просто уведомление. */
notice: ICaughtError | undefined;
}
function formatError(err: unknown, source: string): ICaughtError {
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
return { message: e.message, stack: e.stack || '(нет стека)', source };
// Стек берём ТОЛЬКО у настоящей ошибки. Раньше на любую причину строился
// `new Error(...)` прямо здесь — и в отчёт уходил стек этого конструктора,
// то есть самого ErrorOverlay:
//
// Error
// at vp (request-portal-web-part_<hash>.js:9:397946)
// at t.onUnhandledRejection (...)
//
// Место, где промис реально отклонился, при этом терялось полностью.
if (err instanceof Error) {
return {
message: err.message || describeReason(err),
stack: err.stack || '(нет стека)',
source,
};
}
return { message: describeReason(err), stack: '(стека нет — причина не Error)', source };
}
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
@@ -22,25 +41,43 @@ function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => undefined);
}
function fullTextOf(error: ICaughtError): string {
return `[${error.source}] ${error.message}\n\n${error.stack}`;
}
/**
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
* 1) React-рендер (componentDidCatch);
* 2) синхронные ошибки вне React (window.onerror);
* 3) необработанные rejected promise (unhandledrejection).
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
* «Скопировать», чтобы можно было переслать стек трейс.
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench.
*
* Разделение на fatal и notice — не косметика. Слушатели `error` и
* `unhandledrejection` вешаются на window, то есть срабатывают на ВСЮ страницу
* SharePoint: на другие вебпарты, на скрипты самого SharePoint, на любые
* фоновые промисы. Раньше любое такое отклонение — даже постороннее и
* безобидное — заменяло собой весь портал: вместо открытой заявки пользователь
* получал экран «Необработанное исключение». Именно так это и проявлялось при
* переходе по ссылке на заявку.
*
* Теперь портал заменяется только при падении РЕНДЕРА (там показывать
* действительно нечего), а асинхронные ошибки — угловое уведомление, которое
* можно закрыть; работать оно не мешает. Полный разбор причины при этом всё
* равно уходит в консоль — см. shared/diagnostics.ts.
*/
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
public state: IErrorOverlayState = { error: undefined };
public state: IErrorOverlayState = { fatal: undefined, notice: undefined };
private onWindowError = (event: ErrorEvent): void => {
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
this.showNotice(formatError(event.error || event.message, 'window.onerror'));
};
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
this.showNotice(formatError(event.reason, 'unhandledrejection'));
};
private showNotice(notice: ICaughtError): void {
// Первое уведомление не затираем: за одной ошибкой обычно летит несколько
// производных, а разбираться нужно с исходной.
this.setState((prev) => (prev.notice ? prev : { ...prev, notice }));
}
public componentDidMount(): void {
window.addEventListener('error', this.onWindowError);
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
@@ -51,32 +88,65 @@ export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknow
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
}
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
return { error: formatError(err, 'React render') };
public static getDerivedStateFromError(err: unknown): Partial<IErrorOverlayState> {
return { fatal: formatError(err, 'React render') };
}
public render(): React.ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
const { fatal, notice } = this.state;
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
return (
<div className={styles.overlay}>
<div className={styles.card}>
<div className={styles.title}>Необработанное исключение</div>
<div className={styles.source}>{error.source}</div>
<pre className={styles.stack}>{fullText}</pre>
<div className={styles.actions}>
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
Скопировать
</button>
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
Перезагрузить страницу
</button>
if (fatal) {
const fullText = fullTextOf(fatal);
return (
<div className={styles.overlay}>
<div className={styles.card}>
<div className={styles.title}>Необработанное исключение</div>
<div className={styles.source}>{fatal.source}</div>
<pre className={styles.stack}>{fullText}</pre>
<div className={styles.actions}>
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
Скопировать
</button>
<button
type="button"
className={styles.reloadBtn}
onClick={() => window.location.reload()}
>
Перезагрузить страницу
</button>
</div>
</div>
</div>
</div>
);
}
return (
<>
{this.props.children}
{notice && (
<div className={styles.toast} role="alert">
<div className={styles.toastHead}>
<span className={styles.toastTitle}>Что-то пошло не так</span>
<button
type="button"
className={styles.toastClose}
aria-label="Закрыть уведомление"
onClick={() => this.setState({ notice: undefined })}
>
×
</button>
</div>
<div className={styles.toastText}>{notice.message}</div>
<button
type="button"
className={styles.toastCopy}
onClick={() => copyToClipboard(fullTextOf(notice))}
>
Скопировать детали
</button>
</div>
)}
</>
);
}
}