125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
|||
|
|
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||
|
|
import { Box, Button, Text, config } from 'folds';
|
||
|
|
import { Editor, Transforms } from 'slate';
|
||
|
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||
|
|
import { copyToClipboard } from '../../utils/dom';
|
||
|
|
|
||
|
|
/** Two failures within this window show the notice instead of recovering again. */
|
||
|
|
const SILENT_RETRY_WINDOW_MS = 5000;
|
||
|
|
|
||
|
|
type CapturedError = { message: string; details: string };
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Error boundary around a message composer.
|
||
|
|
*
|
||
|
|
* The composer's known failure mode is a transient render error right after an
|
||
|
|
* autocomplete insert (slate-react's DOM selection sync racing the model), and
|
||
|
|
* the draft is intact when it happens. So the first failure recovers silently:
|
||
|
|
* the selection is cleared and the composer remounts with the same content.
|
||
|
|
* Only a second failure within a few seconds shows the notice, which offers
|
||
|
|
* "Reload composer" and "Copy details" (the error and component stack), so the
|
||
|
|
* underlying bug can be diagnosed. Every failure is also logged to the console.
|
||
|
|
*/
|
||
|
|
export function ComposerErrorBoundary({
|
||
|
|
editor,
|
||
|
|
children,
|
||
|
|
}: {
|
||
|
|
editor: Editor;
|
||
|
|
children: ReactNode;
|
||
|
|
}) {
|
||
|
|
const lastSilentReset = useRef(0);
|
||
|
|
const captured = useRef<CapturedError | undefined>(undefined);
|
||
|
|
|
||
|
|
const clearSelection = () => {
|
||
|
|
try {
|
||
|
|
Transforms.deselect(editor);
|
||
|
|
} catch {
|
||
|
|
/* editor already in a safe state */
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ErrorBoundary
|
||
|
|
onReset={clearSelection}
|
||
|
|
onError={(error, info) => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
const details = [
|
||
|
|
`When: ${new Date().toISOString()}`,
|
||
|
|
`Error: ${error instanceof Error ? `${error.name}: ${message}` : message}`,
|
||
|
|
error instanceof Error && error.stack ? `Stack:\n${error.stack}` : '',
|
||
|
|
info.componentStack ? `Component stack:${info.componentStack}` : '',
|
||
|
|
`Browser: ${navigator.userAgent}`,
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join('\n');
|
||
|
|
captured.current = { message, details };
|
||
|
|
|
||
|
|
console.error('[composer] render error (draft kept):', error, info.componentStack);
|
||
|
|
}}
|
||
|
|
fallbackRender={(props) => (
|
||
|
|
<ComposerFallback
|
||
|
|
{...props}
|
||
|
|
captured={captured.current}
|
||
|
|
lastSilentReset={lastSilentReset}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</ErrorBoundary>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ComposerFallback({
|
||
|
|
resetErrorBoundary,
|
||
|
|
captured,
|
||
|
|
lastSilentReset,
|
||
|
|
}: FallbackProps & {
|
||
|
|
captured?: CapturedError;
|
||
|
|
lastSilentReset: React.MutableRefObject<number>;
|
||
|
|
}) {
|
||
|
|
const silent = Date.now() - lastSilentReset.current > SILENT_RETRY_WINDOW_MS;
|
||
|
|
const [copied, setCopied] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!silent) return;
|
||
|
|
lastSilentReset.current = Date.now();
|
||
|
|
resetErrorBoundary();
|
||
|
|
}, [silent, resetErrorBoundary, lastSilentReset]);
|
||
|
|
|
||
|
|
if (silent) return null;
|
||
|
|
return (
|
||
|
|
<RoomInputPlaceholder
|
||
|
|
role="alert"
|
||
|
|
style={{ padding: config.space.S200 }}
|
||
|
|
direction="Column"
|
||
|
|
alignItems="Center"
|
||
|
|
justifyContent="Center"
|
||
|
|
gap="200"
|
||
|
|
>
|
||
|
|
<Text align="Center">
|
||
|
|
The message composer hit a snag. Your draft is safe — reload the composer to keep typing.
|
||
|
|
</Text>
|
||
|
|
<Box gap="200" justifyContent="Center" wrap="Wrap">
|
||
|
|
<Button size="300" variant="Secondary" fill="Soft" radii="300" onClick={resetErrorBoundary}>
|
||
|
|
<Text size="B300">Reload composer</Text>
|
||
|
|
</Button>
|
||
|
|
{captured && (
|
||
|
|
<Button
|
||
|
|
size="300"
|
||
|
|
variant="Secondary"
|
||
|
|
fill="None"
|
||
|
|
radii="300"
|
||
|
|
onClick={() => {
|
||
|
|
copyToClipboard(captured.details);
|
||
|
|
setCopied(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text size="B300">{copied ? 'Copied' : 'Copy details'}</Text>
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</Box>
|
||
|
|
</RoomInputPlaceholder>
|
||
|
|
);
|
||
|
|
}
|