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:
parent
e5b9f643e1
commit
c6589b7dcf
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user