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:
MartinBraquet
2026-07-23 18:14:30 +02:00
parent f055e2e4e1
commit b3351707e2
8 changed files with 115 additions and 27 deletions

View File

@@ -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(() => {

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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}
/>
</>
) : (

View File

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

View File

@@ -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.',

View File

@@ -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]

View File

@@ -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])