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:
@@ -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"'));
|
||||
});
|
||||
Reference in New Issue
Block a user