From 04c75cd725bab200b311d7dfbb44e9ab92552209 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 30 Jul 2026 09:10:47 +0800 Subject: [PATCH] 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 --- app/renderer/control/main.tsx | 9 ++- app/renderer/editor/SceneDescriptionModal.tsx | 55 ++++++++++--- app/renderer/materials/main.tsx | 9 ++- app/renderer/npcs/NpcDescriptionField.tsx | 40 ++++++---- app/renderer/npcs/NpcsEditorApp.tsx | 29 +++---- app/renderer/npcs/npcsEditorMain.tsx | 9 ++- app/renderer/npcs/npcsMain.tsx | 9 ++- app/renderer/npcs/tiptapEditorSafe.test.ts | 38 +++++++++ app/renderer/npcs/tiptapEditorSafe.ts | 12 +++ app/renderer/presentation/main.tsx | 6 +- app/renderer/sceneDescription/main.tsx | 9 ++- app/renderer/sceneEditor/main.tsx | 9 ++- .../shared/ui/WindowErrorBoundary.tsx | 80 +++++++++++++++++++ .../ui/secondaryWindows.stability.test.ts | 53 ++++++++++++ package.json | 2 +- 15 files changed, 305 insertions(+), 64 deletions(-) create mode 100644 app/renderer/npcs/tiptapEditorSafe.test.ts create mode 100644 app/renderer/npcs/tiptapEditorSafe.ts create mode 100644 app/renderer/shared/ui/WindowErrorBoundary.tsx create mode 100644 app/renderer/shared/ui/secondaryWindows.stability.test.ts diff --git a/app/renderer/control/main.tsx b/app/renderer/control/main.tsx index 7cce1ab..e5bea6d 100644 --- a/app/renderer/control/main.tsx +++ b/app/renderer/control/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 { ControlApp } from './ControlApp'; @@ -13,8 +14,10 @@ if (!rootEl) { createRoot(rootEl).render( - - - + + + + + , ); diff --git a/app/renderer/editor/SceneDescriptionModal.tsx b/app/renderer/editor/SceneDescriptionModal.tsx index f3a7926..cb9ee4e 100644 --- a/app/renderer/editor/SceneDescriptionModal.tsx +++ b/app/renderer/editor/SceneDescriptionModal.tsx @@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes const editor = useEditor({ extensions, content: initialHtml || '', - immediatelyRender: true, + immediatelyRender: false, shouldRerenderOnTransaction: true, editorProps: { attributes: { @@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes const toolbarState = useEditorState({ editor, selector: ({ editor: ed }) => ({ - bold: ed.isActive('bold'), - italic: ed.isActive('italic'), - underline: ed.isActive('underline'), - bulletList: ed.isActive('bulletList'), - orderedList: ed.isActive('orderedList'), - h2: ed.isActive('heading', { level: 2 }), - h3: ed.isActive('heading', { level: 3 }), - blockquote: ed.isActive('blockquote'), + bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')), + italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')), + underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')), + bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')), + orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')), + h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })), + h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })), + blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')), }), }); 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( + <> + + +
+
+ , + document.body, + ); + } + return createPortal( <> + + ); + } +} 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",