From 4c9af57a972a7ab459d5508ae252b6345963f229 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 18:08:33 -0400 Subject: [PATCH] fix(composer): recover silently from a composer render error; capture details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../features/room/ComposerErrorBoundary.tsx | 124 ++++++++++++++++++ src/app/features/room/RoomView.tsx | 41 +----- src/app/features/room/thread/ThreadPanel.tsx | 21 +-- 3 files changed, 140 insertions(+), 46 deletions(-) create mode 100644 src/app/features/room/ComposerErrorBoundary.tsx diff --git a/src/app/features/room/ComposerErrorBoundary.tsx b/src/app/features/room/ComposerErrorBoundary.tsx new file mode 100644 index 000000000..1b634f4c5 --- /dev/null +++ b/src/app/features/room/ComposerErrorBoundary.tsx @@ -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(undefined); + + const clearSelection = () => { + try { + Transforms.deselect(editor); + } catch { + /* editor already in a safe state */ + } + }; + + return ( + { + 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) => ( + + )} + > + {children} + + ); +} + +function ComposerFallback({ + resetErrorBoundary, + captured, + lastSilentReset, +}: FallbackProps & { + captured?: CapturedError; + lastSilentReset: React.MutableRefObject; +}) { + 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 ( + + + The message composer hit a snag. Your draft is safe — reload the composer to keep typing. + + + + {captured && ( + + )} + + + ); +} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a07fa6077..f83b023de 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useMemo, useRef } from 'react'; 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 { ReactEditor } from 'slate-react'; -import { Transforms } from 'slate'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; @@ -15,6 +14,7 @@ import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; +import { ComposerErrorBoundary } from './ComposerErrorBoundary'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; import { useSetting } from '../../state/hooks/settings'; @@ -162,40 +162,7 @@ export function RoomView({ eventId }: { eventId?: string }) { ) : ( <> {canMessage && ( - { - // 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 }) => ( - - The message composer hit a snag. - - - )} - > + - + )} {!canMessage && ( - + + + )}