Fix lint infinite loop, popover usability, debounce timing

Infinite loop (lint hammering server every 2s):
- Add applyingLints ref; set true before clearLintMarks() and
  editor.view.dispatch(tr), false after
- onUpdate returns early when applyingLints is true, so applying
  marks no longer reschedules a lint check or triggers a save

Popover disappearing before you can interact:
- Render via createPortal(…, document.body) — completely outside the
  editor DOM, no stacking context or overflow clipping can interfere
- onMouseDown={e => e.preventDefault()} on the popover prevents the
  editor from losing focus before the button click fires
- Removed setLintPopover(null) from the top of runLint — the popover
  was being force-closed on every auto-check cycle

Debounce: 1.5 s → 3 s — checks after a genuine pause, not mid-word

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-24 22:00:56 -04:00
parent e5b9f643e1
commit c6589b7dcf

View File

@ -1,4 +1,5 @@
import { forwardRef, useEffect, useRef, useImperativeHandle, useState, useCallback } from 'react' import { forwardRef, useEffect, useRef, useImperativeHandle, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react' import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
import { Mark, mergeAttributes, getMarkRange } from '@tiptap/core' import { Mark, mergeAttributes, getMarkRange } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
@ -113,6 +114,7 @@ const Editor = forwardRef(function Editor(
const lintStatusRef = useRef('idle') const lintStatusRef = useRef('idle')
const lintDebounce = useRef(null) const lintDebounce = useRef(null)
const runLintRef = useRef(null) // always holds latest runLint, safe to call from onUpdate closure const runLintRef = useRef(null) // always holds latest runLint, safe to call from onUpdate closure
const applyingLints = useRef(false) // true while we're dispatching mark transactions skip onUpdate
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@ -127,10 +129,14 @@ const Editor = forwardRef(function Editor(
], ],
content: '', content: '',
onUpdate({ editor }) { onUpdate({ editor }) {
// Skip when the update was caused by us applying / clearing lint marks.
// Without this guard, dispatching mark transactions fires onUpdate which
// reschedules the lint check, which applies marks, which fires onUpdate
if (applyingLints.current) return
onChange(stripLintMarks(editor.getJSON())) onChange(stripLintMarks(editor.getJSON()))
// Debounced auto-check: fire 1.5 s after the last keystroke // Debounced auto-check: fire 1.5 s after the last keystroke
clearTimeout(lintDebounce.current) clearTimeout(lintDebounce.current)
lintDebounce.current = setTimeout(() => runLintRef.current?.(), 1500) lintDebounce.current = setTimeout(() => runLintRef.current?.(), 3000)
}, },
}) })
@ -157,12 +163,15 @@ const Editor = forwardRef(function Editor(
if (!editor || lintStatusRef.current === 'checking') return if (!editor || lintStatusRef.current === 'checking') return
lintStatusRef.current = 'checking' lintStatusRef.current = 'checking'
setLintStatus('checking') setLintStatus('checking')
setLintPopover(null) // Don't close an open popover here it gets wiped on every auto-check
// and the user can't interact with it. It closes naturally on click-outside.
try { try {
const { text, posMap } = buildTextMap(editor.state.doc) const { text, posMap } = buildTextMap(editor.state.doc)
if (!text.trim()) { if (!text.trim()) {
applyingLints.current = true
clearLintMarks(editor) clearLintMarks(editor)
applyingLints.current = false
setLintCount(0) setLintCount(0)
setLintStatus('done') setLintStatus('done')
lintStatusRef.current = 'done' lintStatusRef.current = 'done'
@ -171,7 +180,9 @@ const Editor = forwardRef(function Editor(
const data = await api.lintCheck(text) const data = await api.lintCheck(text)
// Clear old marks, then apply all new ones in one transaction // Clear old marks then apply new ones both wrapped in the flag so
// the resulting onUpdate calls are silently ignored.
applyingLints.current = true
clearLintMarks(editor) clearLintMarks(editor)
const { tr, schema } = editor.state const { tr, schema } = editor.state
const lintType = schema.marks.lintError const lintType = schema.marks.lintError
@ -192,11 +203,13 @@ const Editor = forwardRef(function Editor(
})) }))
} }
editor.view.dispatch(tr) editor.view.dispatch(tr)
applyingLints.current = false
setLintCount(data.matches.length) setLintCount(data.matches.length)
setLintStatus('done') setLintStatus('done')
lintStatusRef.current = 'done' lintStatusRef.current = 'done'
} catch (err) { } catch (err) {
applyingLints.current = false
console.error('[lint] Check failed:', err) console.error('[lint] Check failed:', err)
setLintStatus('idle') setLintStatus('idle')
lintStatusRef.current = 'idle' lintStatusRef.current = 'idle'
@ -274,11 +287,13 @@ const Editor = forwardRef(function Editor(
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div> <div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
</div> </div>
{lintPopover && ( {lintPopover && createPortal(
<div <div
className="lint-popover" className="lint-popover"
style={{ top: lintPopover.top, left: lintPopover.left }} style={{ top: lintPopover.top, left: lintPopover.left }}
// Stop clicks inside the popover from bubbling to the editor click handler // Prevent mousedown from stealing focus away from the editor
// without this the editor blurs and the popover may vanish before click fires
onMouseDown={e => e.preventDefault()}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<p className="lint-popover-msg">{lintPopover.message}</p> <p className="lint-popover-msg">{lintPopover.message}</p>
@ -293,7 +308,8 @@ const Editor = forwardRef(function Editor(
<button className="btn btn-ghost lint-ignore" onClick={dismissLintMark}>Ignore</button> <button className="btn btn-ghost lint-ignore" onClick={dismissLintMark}>Ignore</button>
<button className="btn btn-ghost lint-close" onClick={() => setLintPopover(null)}></button> <button className="btn btn-ghost lint-close" onClick={() => setLintPopover(null)}></button>
</div> </div>
</div> </div>,
document.body,
)} )}
</div> </div>
) )