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:
@@ -8,6 +8,7 @@ import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||
|
||||
type NpcDescriptionFieldProps = {
|
||||
html: string;
|
||||
@@ -59,7 +60,8 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: html || '',
|
||||
immediatelyRender: true,
|
||||
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -68,13 +70,17 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
},
|
||||
},
|
||||
onBlur: ({ editor: ed }) => {
|
||||
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
|
||||
const raw = readTipTapHtmlSafe(ed);
|
||||
if (raw == null) return;
|
||||
onCommit(normalizeSceneDescriptionHtml(raw));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = normalizeSceneDescriptionHtml(editor.getHTML());
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
const raw = readTipTapHtmlSafe(editor);
|
||||
if (raw == null) return;
|
||||
const current = normalizeSceneDescriptionHtml(raw);
|
||||
const next = normalizeSceneDescriptionHtml(html);
|
||||
if (current !== next) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
@@ -84,30 +90,30 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
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 })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
if (!editor || editor.isDestroyed) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.descShell}>
|
||||
<div className={modalStyles.toolbar}>
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bold}
|
||||
active={toolbarState?.bold ?? false}
|
||||
title={t('scene.descriptionBold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.italic}
|
||||
active={toolbarState?.italic ?? false}
|
||||
title={t('scene.descriptionItalic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
@@ -117,14 +123,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.h2}
|
||||
active={toolbarState?.h2 ?? false}
|
||||
title={t('scene.descriptionHeading2')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.h3}
|
||||
active={toolbarState?.h3 ?? false}
|
||||
title={t('scene.descriptionHeading3')}
|
||||
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.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bulletList}
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
title={t('scene.descriptionBulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
•
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.orderedList}
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
import type { GraphGroupFilter } from './NpcGraph';
|
||||
import { NpcGroupModal } from './NpcGroupModal';
|
||||
@@ -36,11 +37,6 @@ const NpcGraph = lazy(async () => {
|
||||
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_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
||||
|
||||
@@ -813,18 +809,17 @@ export function NpcsEditorApp() {
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<Suspense fallback={<div className={styles.muted}>{t('npcs.savingWait')}</div>}>
|
||||
<NpcDescriptionField
|
||||
html={selected.description}
|
||||
onCommit={(html) => {
|
||||
if (html === selected.description) return;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
description: html,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<NpcDescriptionField
|
||||
key={selected.id}
|
||||
html={selected.description}
|
||||
onCommit={(html) => {
|
||||
if (html === selected.description) return;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
description: html,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsEditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="НПС">
|
||||
<EditorI18nProvider>
|
||||
<NpcsEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="НПС">
|
||||
<EditorI18nProvider>
|
||||
<NpcsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user