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 { createPortal } from 'react-dom'
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
import { Mark, mergeAttributes, getMarkRange } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
@ -113,6 +114,7 @@ const Editor = forwardRef(function Editor(
const lintStatusRef = useRef('idle')
const lintDebounce = useRef(null)
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({
extensions: [
@ -127,10 +129,14 @@ const Editor = forwardRef(function Editor(
],
content: '',
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()))
// Debounced auto-check: fire 1.5 s after the last keystroke
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
lintStatusRef.current = '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 {
const { text, posMap } = buildTextMap(editor.state.doc)
if (!text.trim()) {
applyingLints.current = true
clearLintMarks(editor)
applyingLints.current = false
setLintCount(0)
setLintStatus('done')
lintStatusRef.current = 'done'
@ -171,7 +180,9 @@ const Editor = forwardRef(function Editor(
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)
const { tr, schema } = editor.state
const lintType = schema.marks.lintError
@ -192,11 +203,13 @@ const Editor = forwardRef(function Editor(
}))
}
editor.view.dispatch(tr)
applyingLints.current = false
setLintCount(data.matches.length)
setLintStatus('done')
lintStatusRef.current = 'done'
} catch (err) {
applyingLints.current = false
console.error('[lint] Check failed:', err)
setLintStatus('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>
{lintPopover && (
{lintPopover && createPortal(
<div
className="lint-popover"
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()}
>
<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-close" onClick={() => setLintPopover(null)}></button>
</div>
</div>
</div>,
document.body,
)}
</div>
)