fix(npcs): stop TipTap crash black screen in secondary windows
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { ControlApp } from './ControlApp';
|
import { ControlApp } from './ControlApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Пульт">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<ControlApp />
|
<ControlApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
content: initialHtml || '',
|
content: initialHtml || '',
|
||||||
immediatelyRender: true,
|
immediatelyRender: false,
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
|||||||
const toolbarState = useEditorState({
|
const toolbarState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor: ed }) => ({
|
selector: ({ editor: ed }) => ({
|
||||||
bold: ed.isActive('bold'),
|
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||||
italic: ed.isActive('italic'),
|
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||||
underline: ed.isActive('underline'),
|
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||||||
bulletList: ed.isActive('bulletList'),
|
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||||
orderedList: ed.isActive('orderedList'),
|
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||||
h2: ed.isActive('heading', { level: 2 }),
|
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||||
h3: ed.isActive('heading', { level: 3 }),
|
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||||
blockquote: ed.isActive('blockquote'),
|
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
|
if (!editor || editor.isDestroyed) return;
|
||||||
|
let raw = '';
|
||||||
|
try {
|
||||||
|
raw = editor.getHTML();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(normalizeSceneDescriptionHtml(raw));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!editor || editor.isDestroyed) {
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={onClose}
|
||||||
|
className={styles.modalBackdrop}
|
||||||
|
/>
|
||||||
|
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={onClose}
|
||||||
|
className={styles.modalClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={modalStyles.editorShell} />
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { MaterialsApp } from './MaterialsApp';
|
import { MaterialsApp } from './MaterialsApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Материалы">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<MaterialsApp />
|
<MaterialsApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
|||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
|
||||||
import styles from './NpcsEditorApp.module.css';
|
import styles from './NpcsEditorApp.module.css';
|
||||||
|
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||||
|
|
||||||
type NpcDescriptionFieldProps = {
|
type NpcDescriptionFieldProps = {
|
||||||
html: string;
|
html: string;
|
||||||
@@ -59,7 +60,8 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
content: html || '',
|
content: html || '',
|
||||||
immediatelyRender: true,
|
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||||||
|
immediatelyRender: false,
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -68,13 +70,17 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onBlur: ({ editor: ed }) => {
|
onBlur: ({ editor: ed }) => {
|
||||||
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
|
const raw = readTipTapHtmlSafe(ed);
|
||||||
|
if (raw == null) return;
|
||||||
|
onCommit(normalizeSceneDescriptionHtml(raw));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor || editor.isDestroyed) return;
|
||||||
const current = normalizeSceneDescriptionHtml(editor.getHTML());
|
const raw = readTipTapHtmlSafe(editor);
|
||||||
|
if (raw == null) return;
|
||||||
|
const current = normalizeSceneDescriptionHtml(raw);
|
||||||
const next = normalizeSceneDescriptionHtml(html);
|
const next = normalizeSceneDescriptionHtml(html);
|
||||||
if (current !== next) {
|
if (current !== next) {
|
||||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||||
@@ -84,30 +90,30 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
const toolbarState = useEditorState({
|
const toolbarState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor: ed }) => ({
|
selector: ({ editor: ed }) => ({
|
||||||
bold: ed.isActive('bold'),
|
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||||
italic: ed.isActive('italic'),
|
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||||
bulletList: ed.isActive('bulletList'),
|
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||||
orderedList: ed.isActive('orderedList'),
|
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||||
h2: ed.isActive('heading', { level: 2 }),
|
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||||
h3: ed.isActive('heading', { level: 3 }),
|
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor || editor.isDestroyed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.descShell}>
|
<div className={styles.descShell}>
|
||||||
<div className={modalStyles.toolbar}>
|
<div className={modalStyles.toolbar}>
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.bold}
|
active={toolbarState?.bold ?? false}
|
||||||
title={t('scene.descriptionBold')}
|
title={t('scene.descriptionBold')}
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.italic}
|
active={toolbarState?.italic ?? false}
|
||||||
title={t('scene.descriptionItalic')}
|
title={t('scene.descriptionItalic')}
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
>
|
>
|
||||||
@@ -117,14 +123,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
<div className={modalStyles.toolbarSep} />
|
<div className={modalStyles.toolbarSep} />
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.h2}
|
active={toolbarState?.h2 ?? false}
|
||||||
title={t('scene.descriptionHeading2')}
|
title={t('scene.descriptionHeading2')}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.h3}
|
active={toolbarState?.h3 ?? false}
|
||||||
title={t('scene.descriptionHeading3')}
|
title={t('scene.descriptionHeading3')}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
>
|
>
|
||||||
@@ -134,14 +140,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
<div className={modalStyles.toolbarSep} />
|
<div className={modalStyles.toolbarSep} />
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.bulletList}
|
active={toolbarState?.bulletList ?? false}
|
||||||
title={t('scene.descriptionBulletList')}
|
title={t('scene.descriptionBulletList')}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
>
|
>
|
||||||
•
|
•
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.orderedList}
|
active={toolbarState?.orderedList ?? false}
|
||||||
title={t('scene.descriptionOrderedList')}
|
title={t('scene.descriptionOrderedList')}
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Button, Input, Select } from '../shared/ui/controls';
|
|||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import { NpcBindingFields } from './NpcBindingFields';
|
import { NpcBindingFields } from './NpcBindingFields';
|
||||||
|
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||||
import { NpcEditModal } from './NpcEditModal';
|
import { NpcEditModal } from './NpcEditModal';
|
||||||
import type { GraphGroupFilter } from './NpcGraph';
|
import type { GraphGroupFilter } from './NpcGraph';
|
||||||
import { NpcGroupModal } from './NpcGroupModal';
|
import { NpcGroupModal } from './NpcGroupModal';
|
||||||
@@ -36,11 +37,6 @@ const NpcGraph = lazy(async () => {
|
|||||||
return { default: mod.NpcGraph };
|
return { default: mod.NpcGraph };
|
||||||
});
|
});
|
||||||
|
|
||||||
const NpcDescriptionField = lazy(async () => {
|
|
||||||
const mod = await import('./NpcDescriptionField');
|
|
||||||
return { default: mod.NpcDescriptionField };
|
|
||||||
});
|
|
||||||
|
|
||||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||||
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
||||||
|
|
||||||
@@ -813,8 +809,8 @@ export function NpcsEditorApp() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||||
<Suspense fallback={<div className={styles.muted}>{t('npcs.savingWait')}</div>}>
|
|
||||||
<NpcDescriptionField
|
<NpcDescriptionField
|
||||||
|
key={selected.id}
|
||||||
html={selected.description}
|
html={selected.description}
|
||||||
onCommit={(html) => {
|
onCommit={(html) => {
|
||||||
if (html === selected.description) return;
|
if (html === selected.description) return;
|
||||||
@@ -824,7 +820,6 @@ export function NpcsEditorApp() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { NpcsEditorApp } from './NpcsEditorApp';
|
import { NpcsEditorApp } from './NpcsEditorApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="НПС">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<NpcsEditorApp />
|
<NpcsEditorApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { NpcsApp } from './NpcsApp';
|
import { NpcsApp } from './NpcsApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="НПС">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<NpcsApp />
|
<NpcsApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: null / destroyed → null', () => {
|
||||||
|
assert.equal(readTipTapHtmlSafe(null), null);
|
||||||
|
assert.equal(readTipTapHtmlSafe(undefined), null);
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: true,
|
||||||
|
getHTML: () => '<p>x</p>',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: getHTML throw → null', () => {
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: false,
|
||||||
|
getHTML: () => {
|
||||||
|
throw new TypeError("Cannot read properties of null (reading 'cached')");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: ok → html', () => {
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: false,
|
||||||
|
getHTML: () => '<p>ok</p>',
|
||||||
|
}),
|
||||||
|
'<p>ok</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Безопасное чтение HTML из TipTap/ProseMirror (StrictMode / destroy mid-flight). */
|
||||||
|
export function readTipTapHtmlSafe(editor: {
|
||||||
|
isDestroyed?: boolean;
|
||||||
|
getHTML: () => string;
|
||||||
|
} | null | undefined): string | null {
|
||||||
|
if (!editor || editor.isDestroyed) return null;
|
||||||
|
try {
|
||||||
|
return editor.getHTML();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { PresentationApp } from './PresentationApp';
|
import { PresentationApp } from './PresentationApp';
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
@@ -11,6 +13,8 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Презентация">
|
||||||
<PresentationApp />
|
<PresentationApp />
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Описание сцены">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<SceneDescriptionApp />
|
<SceneDescriptionApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { SceneEditorApp } from './SceneEditorApp';
|
import { SceneEditorApp } from './SceneEditorApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Редактор сцены">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<SceneEditorApp />
|
<SceneEditorApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type WindowErrorBoundaryProps = {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowErrorBoundaryState = {
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ловит падения React в дочерних окнах (НПС, материалы, редактор сцены…),
|
||||||
|
* чтобы вместо чёрного экрана показать сообщение и кнопку перезагрузки.
|
||||||
|
*/
|
||||||
|
export class WindowErrorBoundary extends React.Component<
|
||||||
|
WindowErrorBoundaryProps,
|
||||||
|
WindowErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: WindowErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): WindowErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error('[WindowErrorBoundary]', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = (): void => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
override render(): React.ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
|
const title = this.props.title ?? 'Ошибка окна';
|
||||||
|
const message = this.state.error.message || String(this.state.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: 24,
|
||||||
|
background: '#09090b',
|
||||||
|
color: '#e4e4e7',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 16 }}>{title}</div>
|
||||||
|
<div style={{ opacity: 0.85, fontSize: 13, maxWidth: 480, lineHeight: 1.45 }}>{message}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
height: 34,
|
||||||
|
padding: '0 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.16)',
|
||||||
|
background: '#27272a',
|
||||||
|
color: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перезагрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rendererRoot = path.resolve(here, '../..');
|
||||||
|
|
||||||
|
const SECONDARY_WINDOW_MAINS = [
|
||||||
|
'npcs/npcsEditorMain.tsx',
|
||||||
|
'npcs/npcsMain.tsx',
|
||||||
|
'materials/main.tsx',
|
||||||
|
'sceneEditor/main.tsx',
|
||||||
|
'sceneDescription/main.tsx',
|
||||||
|
'control/main.tsx',
|
||||||
|
'presentation/main.tsx',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
void test('secondary window mains: WindowErrorBoundary wraps app root', () => {
|
||||||
|
for (const rel of SECONDARY_WINDOW_MAINS) {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, rel), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('WindowErrorBoundary'),
|
||||||
|
`${rel}: must wrap with WindowErrorBoundary to avoid black screen on React crash`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/<WindowErrorBoundary[\s\S]*?>[\s\S]*<\/WindowErrorBoundary>/,
|
||||||
|
`${rel}: WindowErrorBoundary must wrap children`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcDescriptionField: TipTap StrictMode-safe (no black screen on NPC open)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcDescriptionField.tsx'), 'utf8');
|
||||||
|
assert.match(src, /immediatelyRender:\s*false/);
|
||||||
|
assert.ok(src.includes('readTipTapHtmlSafe'));
|
||||||
|
assert.ok(src.includes('isDestroyed'));
|
||||||
|
assert.doesNotMatch(src, /immediatelyRender:\s*true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||||
|
assert.doesNotMatch(src, /controlStyles/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('WindowErrorBoundary component exists and catches errors', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes('getDerivedStateFromError'));
|
||||||
|
assert.ok(src.includes('componentDidCatch'));
|
||||||
|
assert.ok(src.includes('role="alert"'));
|
||||||
|
});
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user