fix(composer): recover silently from a composer render error; capture details
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
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
afd14719ef
commit
4c9af57a97
@@ -0,0 +1,124 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Box, Button, Text, config } from 'folds';
|
import { Box, Text, config } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Transforms } from 'slate';
|
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -15,6 +14,7 @@ import { RoomTimeline } from './RoomTimeline';
|
|||||||
import { RoomViewTyping } from './RoomViewTyping';
|
import { RoomViewTyping } from './RoomViewTyping';
|
||||||
import { RoomTombstone } from './RoomTombstone';
|
import { RoomTombstone } from './RoomTombstone';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
|
import { ComposerErrorBoundary } from './ComposerErrorBoundary';
|
||||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||||
import { Page } from '../../components/page';
|
import { Page } from '../../components/page';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -162,40 +162,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage && (
|
||||||
<ErrorBoundary
|
<ComposerErrorBoundary editor={editor}>
|
||||||
onReset={() => {
|
|
||||||
// The composer crash is a transient bad-selection render
|
|
||||||
// (e.g. after an autocomplete insert); the draft content is
|
|
||||||
// intact. Clear the selection so the remounted composer can
|
|
||||||
// render — the user clicks in to continue, no page refresh.
|
|
||||||
try {
|
|
||||||
Transforms.deselect(editor);
|
|
||||||
} catch {
|
|
||||||
/* editor already in a safe state */
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
fallbackRender={({ resetErrorBoundary }) => (
|
|
||||||
<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.</Text>
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
onClick={resetErrorBoundary}
|
|
||||||
>
|
|
||||||
<Text size="B300">Reload composer</Text>
|
|
||||||
</Button>
|
|
||||||
</RoomInputPlaceholder>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RoomInput
|
<RoomInput
|
||||||
room={room}
|
room={room}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -203,7 +170,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
fileDropContainerRef={roomViewRef}
|
fileDropContainerRef={roomViewRef}
|
||||||
ref={roomInputRef}
|
ref={roomInputRef}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ComposerErrorBoundary>
|
||||||
)}
|
)}
|
||||||
{!canMessage && (
|
{!canMessage && (
|
||||||
<RoomInputPlaceholder
|
<RoomInputPlaceholder
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useKeyDown } from '../../../hooks/useKeyDown';
|
|||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { RoomInput } from '../RoomInput';
|
import { RoomInput } from '../RoomInput';
|
||||||
|
import { ComposerErrorBoundary } from '../ComposerErrorBoundary';
|
||||||
import {
|
import {
|
||||||
getThreadNotificationModeIcon,
|
getThreadNotificationModeIcon,
|
||||||
ThreadNotificationModeSwitcher,
|
ThreadNotificationModeSwitcher,
|
||||||
@@ -204,15 +205,17 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
|||||||
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
||||||
<RoomInput
|
<ComposerErrorBoundary editor={editor}>
|
||||||
room={room}
|
<RoomInput
|
||||||
roomId={room.roomId}
|
room={room}
|
||||||
threadRootId={threadId}
|
roomId={room.roomId}
|
||||||
editor={editor}
|
threadRootId={threadId}
|
||||||
editableName="ThreadInput"
|
editor={editor}
|
||||||
fileDropContainerRef={fileDropContainerRef}
|
editableName="ThreadInput"
|
||||||
compactLayout
|
fileDropContainerRef={fileDropContainerRef}
|
||||||
/>
|
compactLayout
|
||||||
|
/>
|
||||||
|
</ComposerErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user