Files
cinny/src/app/features/room/ComposerErrorBoundary.tsx
T
Lotus CIandClaude Opus 5.5 4c9af57a97
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 3m3s
fix(composer): recover silently from a composer render error; capture details
Reported on chat.lotusguild.org: picking a name from the @-mention list
showed "The message composer hit a snag." — clicking OK continued with the
draft intact. 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; 477df4ae fixed one such path). It did not reproduce locally in 60
attempts (click / Enter / Tab, fast and slow, display names with spaces and
emoji, Chromium and WebKit), so this makes it harmless and diagnosable:

- ComposerErrorBoundary: the first failure clears the selection and remounts
  the composer immediately, with no notice (the draft is intact). A second
  failure within 5 s shows the notice, now saying the draft is safe, with
  "Reload composer" and "Copy details" (time, error, stack, component stack,
  browser). Every failure is logged to the console as "[composer] render
  error".
- Used for the room composer and, newly, the thread panel composer (which had
  no boundary, so the same error took the whole panel down).

Verified with a temporary injected crash (not committed): one crash recovers
with the draft kept and no notice; two within 5 s show the notice, Copy
details copies the report, Reload composer restores the full draft.
Chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:08:33 -04:00

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>
);
}