diff --git a/app/renderer/npcs/npcsEditorMain.tsx b/app/renderer/npcs/npcsEditorMain.tsx
index a660221..9e9b1a6 100644
--- a/app/renderer/npcs/npcsEditorMain.tsx
+++ b/app/renderer/npcs/npcsEditorMain.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
+import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { NpcsEditorApp } from './NpcsEditorApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/app/renderer/npcs/npcsMain.tsx b/app/renderer/npcs/npcsMain.tsx
index ed95f4f..701496a 100644
--- a/app/renderer/npcs/npcsMain.tsx
+++ b/app/renderer/npcs/npcsMain.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
+import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { NpcsApp } from './NpcsApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/app/renderer/npcs/tiptapEditorSafe.test.ts b/app/renderer/npcs/tiptapEditorSafe.test.ts
new file mode 100644
index 0000000..cf66f9e
--- /dev/null
+++ b/app/renderer/npcs/tiptapEditorSafe.test.ts
@@ -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: () => '
x
',
+ }),
+ 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: () => '
ok
',
+ }),
+ '
ok
',
+ );
+});
diff --git a/app/renderer/npcs/tiptapEditorSafe.ts b/app/renderer/npcs/tiptapEditorSafe.ts
new file mode 100644
index 0000000..c4cbebc
--- /dev/null
+++ b/app/renderer/npcs/tiptapEditorSafe.ts
@@ -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;
+ }
+}
diff --git a/app/renderer/presentation/main.tsx b/app/renderer/presentation/main.tsx
index 792c422..f95cbaa 100644
--- a/app/renderer/presentation/main.tsx
+++ b/app/renderer/presentation/main.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
+import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
+
import { PresentationApp } from './PresentationApp';
const rootEl = document.getElementById('root');
@@ -11,6 +13,8 @@ if (!rootEl) {
createRoot(rootEl).render(
-
+
+
+
,
);
diff --git a/app/renderer/sceneDescription/main.tsx b/app/renderer/sceneDescription/main.tsx
index ae4d9fa..78c88f1 100644
--- a/app/renderer/sceneDescription/main.tsx
+++ b/app/renderer/sceneDescription/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
+import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { SceneDescriptionApp } from './SceneDescriptionApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/app/renderer/sceneEditor/main.tsx b/app/renderer/sceneEditor/main.tsx
index ebac610..7fe3f9c 100644
--- a/app/renderer/sceneEditor/main.tsx
+++ b/app/renderer/sceneEditor/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
+import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { SceneEditorApp } from './SceneEditorApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/app/renderer/shared/ui/WindowErrorBoundary.tsx b/app/renderer/shared/ui/WindowErrorBoundary.tsx
new file mode 100644
index 0000000..fe590d5
--- /dev/null
+++ b/app/renderer/shared/ui/WindowErrorBoundary.tsx
@@ -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 (
+
+
{title}
+
{message}
+
+
+ );
+ }
+}
diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts
new file mode 100644
index 0000000..d254f92
--- /dev/null
+++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts
@@ -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,
+ /
[\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"'));
+});
diff --git a/package.json b/package.json
index 967bbd6..4ce3565 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"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:write": "prettier . --write",
"postinstall": "patch-package",