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 { 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>
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user