From c6589b7dcfefcebb2a3fdc896d160360328042db Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 24 May 2026 22:00:56 -0400 Subject: [PATCH] Fix lint infinite loop, popover usability, debounce timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/Editor.jsx | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index 82fee85..ac25046 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -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(
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
- {lintPopover && ( + {lintPopover && createPortal(
e.preventDefault()} onClick={e => e.stopPropagation()} >

{lintPopover.message}

@@ -293,7 +308,8 @@ const Editor = forwardRef(function Editor(
- + , + document.body, )} )