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:
Ivan Fontosh
2026-07-30 09:10:47 +08:00
parent e687303c57
commit 04c75cd725
15 changed files with 305 additions and 64 deletions
+23 -17
View File
@@ -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()}
>