отображение ошибки
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
@@ -1,4 +1,4 @@
import { installErrorLogging } from './diagnostics'; import { describeReason, installErrorLogging } from './diagnostics';
// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это // jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это
// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому // сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому
@@ -62,3 +62,47 @@ describe('installErrorLogging', () => {
expect(count).toBe(1); expect(count).toBe(1);
}); });
}); });
// describeReason — то, что пользователь реально видит в отчёте об ошибке (и в
// угловом уведомлении ErrorOverlay), поэтому пустых строк он давать не должен
// ни на одном виде причины.
describe('describeReason', () => {
it('промис без причины описывается словами, а не пустой строкой', () => {
// Здесь и ломалось: JSON.stringify(undefined) возвращает undefined, и
// сообщение схлопывалось в пустоту — по отчёту нельзя было понять даже
// того, что причины не было вовсе.
expect(describeReason(undefined)).toContain('без причины');
});
it('null тоже описывается', () => {
expect(describeReason(null)).toContain('null');
});
it('ошибка — имя и сообщение', () => {
expect(describeReason(new TypeError('нет доступа'))).toBe('TypeError: нет доступа');
});
it('ошибка с пустым сообщением не даёт пустую строку', () => {
expect(describeReason(new Error(''))).toContain('пустое сообщение');
});
it('строка возвращается как есть', () => {
expect(describeReason('сервер недоступен')).toBe('сервер недоступен');
});
it('обычный объект сериализуется', () => {
expect(describeReason({ status: 404 })).toContain('404');
});
it('циклическая ссылка не роняет разбор — остаются ключи', () => {
// Внутри ошибок PnPjs лежит Response со ссылками на себя: JSON.stringify на
// таком объекте бросает, и раньше это происходило прямо внутри обработчика
// события.
const circular: Record<string, unknown> = { status: 500 };
circular.self = circular;
const text = describeReason(circular);
expect(text).toContain('status');
expect(text).toContain('self');
});
});
@@ -25,14 +25,30 @@ let installed = false;
// Причиной отклонения может быть что угодно, не только Error — у PnPjs это // Причиной отклонения может быть что угодно, не только Error — у PnPjs это
// бывает объект с полями ответа. Разбираем по факту, а не по ожиданию. // бывает объект с полями ответа. Разбираем по факту, а не по ожиданию.
function describeReason(reason: unknown): string { //
if (reason instanceof Error) return `${reason.name}: ${reason.message}`; // Отдельно разобраны случаи, из-за которых раньше в отчёт уходила пустая
if (typeof reason === 'string') return reason; // строка. Промис можно отклонить вообще без причины (`Promise.reject()`), и
// тогда reason === undefined: JSON.stringify возвращает на нём НЕ строку, а
// undefined, из-за чего сообщение схлопывалось в пустоту и по отчёту нельзя
// было понять даже того, что причины не было вовсе.
export function describeReason(reason: unknown): string {
if (reason === undefined) return 'промис отклонён без причины (reject без аргумента)';
if (reason === null) return 'промис отклонён значением null';
if (reason instanceof Error) {
return `${reason.name}: ${reason.message || '(пустое сообщение)'}`;
}
if (typeof reason === 'string') return reason || '(пустая строка)';
if (typeof reason !== 'object') return `${typeof reason}: ${String(reason)}`;
// Тип объекта указываем всегда: у PnPjs/SharePoint по одному имени класса
// (HttpRequestError и т.п.) уже понятно, куда смотреть.
const kind = (reason as { constructor?: { name?: string } }).constructor?.name || 'Object';
try { try {
return JSON.stringify(reason); return `${kind}: ${JSON.stringify(reason)}`;
} catch { } catch {
// Циклические ссылки — тогда хотя бы тип. // Циклические ссылки (внутри ошибок PnPjs лежит Response) — тогда хотя бы
return Object.prototype.toString.call(reason); // перечислим ключи: по ним видно, что это за объект.
return `${kind}: {${Object.keys(reason as object).join(', ')}}`;
} }
} }
@@ -83,3 +83,73 @@
border-color: $gold-400; 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 * as React from 'react';
import { describeReason } from '../../diagnostics';
import styles from './ErrorOverlay.module.scss'; import styles from './ErrorOverlay.module.scss';
interface ICaughtError { interface ICaughtError {
@@ -8,12 +9,30 @@ interface ICaughtError {
} }
interface IErrorOverlayState { interface IErrorOverlayState {
error: ICaughtError | undefined; /** Падение самого рендера — дерево сломано, показывать нечего. */
fatal: ICaughtError | undefined;
/** Асинхронная ошибка со страницы — портал работает, это просто уведомление. */
notice: ICaughtError | undefined;
} }
function formatError(err: unknown, source: string): ICaughtError { 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 в буфер обмена, чтобы кинуть разработчику. Ошибка // Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
@@ -22,25 +41,43 @@ function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => undefined); navigator.clipboard.writeText(text).catch(() => undefined);
} }
function fullTextOf(error: ICaughtError): string {
return `[${error.source}] ${error.message}\n\n${error.stack}`;
}
/** /**
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench: * Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench.
* 1) React-рендер (componentDidCatch); *
* 2) синхронные ошибки вне React (window.onerror); * Разделение на fatal и notice — не косметика. Слушатели `error` и
* 3) необработанные rejected promise (unhandledrejection). * `unhandledrejection` вешаются на window, то есть срабатывают на ВСЮ страницу
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой * SharePoint: на другие вебпарты, на скрипты самого SharePoint, на любые
* «Скопировать», чтобы можно было переслать стек трейс. * фоновые промисы. Раньше любое такое отклонение — даже постороннее и
* безобидное — заменяло собой весь портал: вместо открытой заявки пользователь
* получал экран «Необработанное исключение». Именно так это и проявлялось при
* переходе по ссылке на заявку.
*
* Теперь портал заменяется только при падении РЕНДЕРА (там показывать
* действительно нечего), а асинхронные ошибки — угловое уведомление, которое
* можно закрыть; работать оно не мешает. Полный разбор причины при этом всё
* равно уходит в консоль — см. shared/diagnostics.ts.
*/ */
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> { 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 => { 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 => { 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 { public componentDidMount(): void {
window.addEventListener('error', this.onWindowError); window.addEventListener('error', this.onWindowError);
window.addEventListener('unhandledrejection', this.onUnhandledRejection); window.addEventListener('unhandledrejection', this.onUnhandledRejection);
@@ -51,32 +88,65 @@ export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknow
window.removeEventListener('unhandledrejection', this.onUnhandledRejection); window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
} }
public static getDerivedStateFromError(err: unknown): IErrorOverlayState { public static getDerivedStateFromError(err: unknown): Partial<IErrorOverlayState> {
return { error: formatError(err, 'React render') }; return { fatal: formatError(err, 'React render') };
} }
public render(): React.ReactNode { public render(): React.ReactNode {
const { error } = this.state; const { fatal, notice } = this.state;
if (!error) return this.props.children;
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`; if (fatal) {
const fullText = fullTextOf(fatal);
return ( return (
<div className={styles.overlay}> <div className={styles.overlay}>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.title}>Необработанное исключение</div> <div className={styles.title}>Необработанное исключение</div>
<div className={styles.source}>{error.source}</div> <div className={styles.source}>{fatal.source}</div>
<pre className={styles.stack}>{fullText}</pre> <pre className={styles.stack}>{fullText}</pre>
<div className={styles.actions}> <div className={styles.actions}>
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}> <button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
Скопировать Скопировать
</button> </button>
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}> <button
Перезагрузить страницу type="button"
</button> className={styles.reloadBtn}
onClick={() => window.location.reload()}
>
Перезагрузить страницу
</button>
</div>
</div> </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>
)}
</>
); );
} }
} }