diff --git a/src/webparts/requestPortal/shared/diagnostics.test.ts b/src/webparts/requestPortal/shared/diagnostics.test.ts index fbae34f..aa08953 100644 --- a/src/webparts/requestPortal/shared/diagnostics.test.ts +++ b/src/webparts/requestPortal/shared/diagnostics.test.ts @@ -1,4 +1,4 @@ -import { installErrorLogging } from './diagnostics'; +import { describeReason, installErrorLogging } from './diagnostics'; // jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это // сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому @@ -62,3 +62,47 @@ describe('installErrorLogging', () => { 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 = { status: 500 }; + circular.self = circular; + + const text = describeReason(circular); + expect(text).toContain('status'); + expect(text).toContain('self'); + }); +}); diff --git a/src/webparts/requestPortal/shared/diagnostics.ts b/src/webparts/requestPortal/shared/diagnostics.ts index 6df3d74..58552bb 100644 --- a/src/webparts/requestPortal/shared/diagnostics.ts +++ b/src/webparts/requestPortal/shared/diagnostics.ts @@ -25,14 +25,30 @@ let installed = false; // Причиной отклонения может быть что угодно, не только 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 { - return JSON.stringify(reason); + return `${kind}: ${JSON.stringify(reason)}`; } catch { - // Циклические ссылки — тогда хотя бы тип. - return Object.prototype.toString.call(reason); + // Циклические ссылки (внутри ошибок PnPjs лежит Response) — тогда хотя бы + // перечислим ключи: по ним видно, что это за объект. + return `${kind}: {${Object.keys(reason as object).join(', ')}}`; } } diff --git a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss index 97dc0c7..8563c27 100644 --- a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss +++ b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx index 76bfd9c..e7a9b35 100644 --- a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx +++ b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx @@ -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_.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, 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 { + 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 ( -
-
-
Необработанное исключение
-
{error.source}
-
{fullText}
-
- - + if (fatal) { + const fullText = fullTextOf(fatal); + return ( +
+
+
Необработанное исключение
+
{fatal.source}
+
{fullText}
+
+ + +
-
+ ); + } + + return ( + <> + {this.props.children} + {notice && ( +
+
+ Что-то пошло не так + +
+
{notice.message}
+ +
+ )} + ); } }