mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
Optimize editor performance by reducing excessive re-renders
- Introduced `useEditorState` hook for targeted subscriptions in components (e.g., text length, active marks, editor state). - Disabled global re-rendering on every transaction within the editor. - Improved typing responsiveness across components by limiting reflows and re-renders caused by keystrokes. - Updated components (`LLMExtractSection`, `comment-input`, `editable-bio`, etc.) to leverage `useEditorState` for localized updates. - Refined floating toolbar behavior with `requestAnimationFrame` for smoother scrolling and reduced layout thrashing.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {Editor} from '@tiptap/core'
|
||||
import {useEditorState} from '@tiptap/react'
|
||||
import {MIN_BIO_LENGTH} from 'common/constants'
|
||||
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
|
||||
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
|
||||
@@ -174,7 +175,12 @@ export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioPr
|
||||
"Tell us all the details about yourself — and what you're looking for!",
|
||||
),
|
||||
})
|
||||
const textLength = editor?.getText().length ?? 0
|
||||
// Subscribe to the text length; the editor no longer re-renders us per keystroke (see editor.tsx).
|
||||
const textLength =
|
||||
useEditorState({
|
||||
editor,
|
||||
selector: ({editor}) => editor?.getText().length ?? 0,
|
||||
}) ?? 0
|
||||
const remainingChars = MIN_BIO_LENGTH - textLength
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PaperAirplaneIcon} from '@heroicons/react/24/solid'
|
||||
import {Editor} from '@tiptap/react'
|
||||
import {Editor, useEditorState} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import {MAX_COMMENT_LENGTH, ReplyToUserInfo} from 'common/comment'
|
||||
import {User} from 'common/user'
|
||||
@@ -157,6 +157,14 @@ export function CommentInputTextArea(props: {
|
||||
} = props
|
||||
const t = useT()
|
||||
|
||||
// The editor no longer re-renders this component on every keystroke (see editor.tsx), so subscribe
|
||||
// to just the emptiness of the doc — that's all the send buttons below need to enable/disable.
|
||||
const isEmpty =
|
||||
useEditorState({
|
||||
editor,
|
||||
selector: ({editor}) => editor?.isEmpty ?? true,
|
||||
}) ?? true
|
||||
|
||||
useEffect(() => {
|
||||
editor?.setEditable(!isSubmitting)
|
||||
}, [isSubmitting, editor])
|
||||
@@ -211,7 +219,7 @@ export function CommentInputTextArea(props: {
|
||||
{user && !isSubmitting && submit && commentTypes.includes('repost') && (
|
||||
<Tooltip text={'Post question & comment to your followers'} className={'mt-2'}>
|
||||
<button
|
||||
disabled={isDisabled || !editor || editor.isEmpty}
|
||||
disabled={isDisabled || !editor || isEmpty}
|
||||
className="text-ink-500 hover:text-ink-700 active:bg-ink-300 disabled:text-ink-300 px-2 transition-colors"
|
||||
onClick={() => submit('repost')}
|
||||
>
|
||||
@@ -242,7 +250,7 @@ export function CommentInputTextArea(props: {
|
||||
<button
|
||||
data-testid="conversation-message-submit"
|
||||
className="text-ink-500 hover:text-ink-700 active:bg-ink-300 disabled:text-ink-300 px-4 transition-colors"
|
||||
disabled={isDisabled || !editor || editor.isEmpty}
|
||||
disabled={isDisabled || !editor || isEmpty}
|
||||
onClick={() => submit('comment')}
|
||||
>
|
||||
<PaperAirplaneIcon className="m-0 h-[25px] w-[22px] p-0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {useEditorState} from '@tiptap/react'
|
||||
import {formLink} from 'common/constants'
|
||||
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
|
||||
import Link from 'next/link'
|
||||
@@ -22,7 +23,11 @@ export function ContactComponent() {
|
||||
placeholder: t('contact.editor.placeholder', 'Contact us here...'), // localized placeholder
|
||||
})
|
||||
|
||||
const showButton = !!editor?.getText().length
|
||||
// Subscribe to non-emptiness; the editor no longer re-renders us per keystroke (see editor.tsx).
|
||||
const showButton = useEditorState({
|
||||
editor,
|
||||
selector: ({editor}) => !!editor && !editor.isEmpty,
|
||||
})
|
||||
|
||||
return (
|
||||
<Col className="max-w-3xl mx-auto">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {CheckIcon, LinkIcon, TrashIcon} from '@heroicons/react/24/solid'
|
||||
import {Editor} from '@tiptap/core'
|
||||
import {BubbleMenu} from '@tiptap/react'
|
||||
import {BubbleMenu, useEditorState} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import {getUrl} from 'common/util/parse'
|
||||
import {Bold, Italic, Type} from 'lucide-react'
|
||||
@@ -17,7 +17,23 @@ export function FloatingFormatMenu(props: {
|
||||
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
if (!editor) return null
|
||||
// The editor no longer re-renders this component on every transaction (see editor.tsx), so subscribe
|
||||
// to just the active marks we highlight. This re-renders only the bubble menu when they change.
|
||||
const active = useEditorState({
|
||||
editor,
|
||||
selector: ({editor}) =>
|
||||
editor
|
||||
? {
|
||||
h1: editor.isActive('heading', {level: 1}),
|
||||
h2: editor.isActive('heading', {level: 2}),
|
||||
bold: editor.isActive('bold'),
|
||||
italic: editor.isActive('italic'),
|
||||
link: editor.isActive('link'),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
if (!editor || !active) return null
|
||||
|
||||
const setLink = () => {
|
||||
const href = url && getUrl(url)
|
||||
@@ -45,12 +61,12 @@ export function FloatingFormatMenu(props: {
|
||||
<IconButton
|
||||
icon={Type}
|
||||
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
|
||||
isActive={editor.isActive('heading', {level: 1})}
|
||||
isActive={active.h1}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Type}
|
||||
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
|
||||
isActive={editor.isActive('heading', {level: 2})}
|
||||
isActive={active.h2}
|
||||
className="!h-4"
|
||||
/>
|
||||
<Divider />
|
||||
@@ -59,17 +75,17 @@ export function FloatingFormatMenu(props: {
|
||||
<IconButton
|
||||
icon={Bold}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
isActive={active.bold}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Italic}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
isActive={active.italic}
|
||||
/>
|
||||
<IconButton
|
||||
icon={LinkIcon}
|
||||
onClick={() => (editor.isActive('link') ? unsetLink() : setUrl(''))}
|
||||
isActive={editor.isActive('link')}
|
||||
onClick={() => (active.link ? unsetLink() : setUrl(''))}
|
||||
isActive={active.link}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {CheckIcon} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {FilterFields} from 'common/filters'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Checkbox} from 'web/components/widgets/checkbox'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
export function IncompleteProfilesToggle(props: {
|
||||
@@ -19,13 +19,31 @@ export function IncompleteProfilesToggle(props: {
|
||||
|
||||
const checked = filters.shortBio || false
|
||||
|
||||
// Styled as a chip, matching the connection filter's OptionChip, so the whole rail reads as one
|
||||
// product. Still a real visually-hidden checkbox underneath, so keyboard and screen-reader
|
||||
// semantics are unchanged.
|
||||
return (
|
||||
<Row className={clsx('mr-2', checked && 'font-semibold')}>
|
||||
<Checkbox
|
||||
label={label}
|
||||
checked={checked}
|
||||
toggle={(checked) => updateFilter({shortBio: checked ? true : undefined})}
|
||||
/>
|
||||
<Row className="mr-2">
|
||||
<label
|
||||
className={clsx(
|
||||
'group relative inline-flex cursor-pointer select-none items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-all duration-150',
|
||||
'focus-within:ring-2 focus-within:ring-primary-400 focus-within:ring-offset-1 focus-within:ring-offset-canvas-50',
|
||||
checked
|
||||
? 'border-cta bg-cta text-white shadow-[0_2px_8px_rgba(193,127,62,0.28)]'
|
||||
: 'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateFilter({shortBio: e.target.checked ? true : undefined})
|
||||
}
|
||||
/>
|
||||
{checked && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={3} />}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</label>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useEditorState} from '@tiptap/react'
|
||||
import {isUrl} from 'common/parsing'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
@@ -23,7 +24,15 @@ export function LLMExtractSection({
|
||||
progress,
|
||||
}: LLMExtractSectionProps) {
|
||||
const t = useT()
|
||||
const parsingText = parsingEditor?.getText?.()
|
||||
// Subscribe to just the editor's text so only this small section re-renders per keystroke — enough
|
||||
// to keep the button's URL-vs-text label live. Previously `onChange` did `setParsingEditor({...})`
|
||||
// on every keystroke, which re-rendered the entire profile form (and produced a broken non-Editor
|
||||
// copy), which is what made typing here lag.
|
||||
const parsingText =
|
||||
useEditorState({
|
||||
editor: parsingEditor ?? null,
|
||||
selector: ({editor}) => editor?.getText?.() ?? '',
|
||||
}) ?? ''
|
||||
|
||||
return (
|
||||
<Col className={'gap-4'}>
|
||||
@@ -43,10 +52,6 @@ export function LLMExtractSection({
|
||||
onEditor={(e) => {
|
||||
if (e) setParsingEditor(e)
|
||||
}}
|
||||
onChange={() => {
|
||||
// Trigger re-render to update button state and text on every key stroke
|
||||
setParsingEditor({...parsingEditor})
|
||||
}}
|
||||
placeholder={t(
|
||||
'profile.llm.extract.placeholder',
|
||||
'Insert a URL or paste your profile content here.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {CheckIcon} from '@heroicons/react/24/outline'
|
||||
import {useEditorState} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import {MAX_COMMENT_LENGTH} from 'common/comment'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
@@ -171,7 +172,13 @@ export const SendMessageButton = (props: {
|
||||
|
||||
const MIN_CHARS = 200
|
||||
|
||||
const charCount = editor?.getText().trim().length ?? 0
|
||||
// Subscribe to just the trimmed length; the editor no longer re-renders us per keystroke (see
|
||||
// editor.tsx), and this drives the live progress bar / unlock threshold below.
|
||||
const charCount =
|
||||
useEditorState({
|
||||
editor,
|
||||
selector: ({editor}) => editor?.getText().trim().length ?? 0,
|
||||
}) ?? 0
|
||||
const pct = Math.min((charCount / MIN_CHARS) * 100, 100)
|
||||
const isReady = charCount >= MIN_CHARS
|
||||
const firstName = toUser.name.split(' ')[0]
|
||||
|
||||
@@ -133,6 +133,12 @@ export function useTextEditor(props: {
|
||||
transformPastedHTML: (html) => html,
|
||||
},
|
||||
immediatelyRender: false,
|
||||
// Don't re-render the whole host component on every transaction. Otherwise every keystroke
|
||||
// re-renders whatever renders this editor — on the messages page that's the entire conversation
|
||||
// (all ChatMessageItems), which is what made typing lag. Components that display editor-derived
|
||||
// state (send-button enabled-ness, char counts, the format menu's active marks) subscribe to just
|
||||
// what they need via `useEditorState` instead, so only those tiny components re-render.
|
||||
shouldRerenderOnTransaction: false,
|
||||
onUpdate: ({editor}) => {
|
||||
if (key) {
|
||||
save(editor.getJSON())
|
||||
@@ -307,7 +313,7 @@ export function TextEditor(props: {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const keepToolbarInView = () => {
|
||||
const measureAndScroll = () => {
|
||||
if (!editor.isFocused) return
|
||||
const el = toolbarRef.current
|
||||
if (!el) return
|
||||
@@ -335,11 +341,28 @@ export function TextEditor(props: {
|
||||
if (overshoot > 0) window.scrollBy(0, overshoot)
|
||||
else if (rect.top < 0) el.scrollIntoView({block: 'nearest'})
|
||||
}
|
||||
|
||||
// Coalesce the measurement into a single animation frame. TipTap fires `update` *and*
|
||||
// `selectionUpdate` for every keystroke, and `measureAndScroll` reads layout
|
||||
// (getBoundingClientRect / getComputedStyle / a document-wide querySelectorAll), which forces a
|
||||
// synchronous reflow — so running it inline made every character pay for two full-page reflows on
|
||||
// the input's critical path, which is what made typing lag on large DOMs (e.g. a message channel).
|
||||
// Deferring to rAF collapses the bursts into at most one measurement per frame, off the keystroke
|
||||
// path, so typing stays smooth while the toolbar still gets nudged back into view.
|
||||
let frame = 0
|
||||
const keepToolbarInView = () => {
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
measureAndScroll()
|
||||
})
|
||||
}
|
||||
editor.on('update', keepToolbarInView)
|
||||
editor.on('selectionUpdate', keepToolbarInView)
|
||||
return () => {
|
||||
editor.off('update', keepToolbarInView)
|
||||
editor.off('selectionUpdate', keepToolbarInView)
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user