From 915e616ff593bd788239895cb4ed062fe4cd12c4 Mon Sep 17 00:00:00 2001 From: nsrCodes Date: Fri, 27 Jun 2025 09:13:36 +0530 Subject: [PATCH] fix: avoid random updates to view while the user is typing --- core/src/timeoutLatch.ts | 98 +++++++++++++++++++++++++++++++++++++++ core/src/useCodeMirror.ts | 42 +++++++++++++++-- 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 core/src/timeoutLatch.ts diff --git a/core/src/timeoutLatch.ts b/core/src/timeoutLatch.ts new file mode 100644 index 000000000..528355646 --- /dev/null +++ b/core/src/timeoutLatch.ts @@ -0,0 +1,98 @@ +// Setting / Unsetting timeouts for every keystroke was a significant overhead +// Inspired from https://github.com/iostreamer-X/timeout-latch + +export class TimeoutLatch { + private timeLeftMS: number; + private timeoutMS: number; + private isCancelled = false; + private isTimeExhausted = false; + private callbacks: Function[] = []; + + constructor(callback: Function, timeoutMS: number) { + this.timeLeftMS = timeoutMS; + this.timeoutMS = timeoutMS; + this.callbacks.push(callback); + } + + tick(): void { + if (!this.isCancelled && !this.isTimeExhausted) { + this.timeLeftMS--; + if (this.timeLeftMS <= 0) { + this.isTimeExhausted = true; + const callbacks = this.callbacks.slice(); + this.callbacks.length = 0; + callbacks.forEach((callback) => { + try { + callback(); + } catch (error) { + console.error('TimeoutLatch callback error:', error); + } + }); + } + } + } + + cancel(): void { + this.isCancelled = true; + this.callbacks.length = 0; + } + + reset(): void { + this.timeLeftMS = this.timeoutMS; + this.isCancelled = false; + this.isTimeExhausted = false; + } + + get isDone(): boolean { + return this.isCancelled || this.isTimeExhausted; + } +} + +class Scheduler { + private interval: NodeJS.Timeout | null = null; + private latches = new Set(); + + add(latch: TimeoutLatch): void { + this.latches.add(latch); + this.start(); + } + + remove(latch: TimeoutLatch): void { + this.latches.delete(latch); + if (this.latches.size === 0) { + this.stop(); + } + } + + private start(): void { + if (this.interval === null) { + this.interval = setInterval(() => { + this.latches.forEach((latch) => { + latch.tick(); + if (latch.isDone) { + this.remove(latch); + } + }); + }, 1); + } + } + + private stop(): void { + if (this.interval !== null) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +let globalScheduler: Scheduler | null = null; + +export const getScheduler = (): Scheduler => { + if (typeof window === 'undefined') { + return new Scheduler(); + } + if (!globalScheduler) { + globalScheduler = new Scheduler(); + } + return globalScheduler; +}; diff --git a/core/src/useCodeMirror.ts b/core/src/useCodeMirror.ts index a24caf7de..7e0abd02e 100644 --- a/core/src/useCodeMirror.ts +++ b/core/src/useCodeMirror.ts @@ -4,8 +4,10 @@ import { EditorView, type ViewUpdate } from '@codemirror/view'; import { getDefaultExtensions } from './getDefaultExtensions'; import { getStatistics } from './utils'; import { type ReactCodeMirrorProps } from '.'; +import { TimeoutLatch, getScheduler } from './timeoutLatch'; const External = Annotation.define(); +const TYPING_TIMOUT = 200; // ms export interface UseCodeMirror extends ReactCodeMirrorProps { container?: HTMLDivElement | null; @@ -41,6 +43,8 @@ export function useCodeMirror(props: UseCodeMirror) { const [container, setContainer] = useState(); const [view, setView] = useState(); const [state, setState] = useState(); + const typingLatch = useState<{ current: TimeoutLatch | null }>(() => ({ current: null }))[0]; + const pendingUpdate = useState<{ current: (() => void) | null }>(() => ({ current: null }))[0]; const defaultThemeOption = EditorView.theme({ '&': { height, @@ -62,6 +66,20 @@ export function useCodeMirror(props: UseCodeMirror) { // If transaction is market as remote we don't have to call `onChange` handler again !vu.transactions.some((tr) => tr.annotation(External)) ) { + if (typingLatch.current) { + typingLatch.current.reset(); + } else { + typingLatch.current = new TimeoutLatch(() => { + if (pendingUpdate.current) { + const forceUpdate = pendingUpdate.current; + pendingUpdate.current = null; + forceUpdate(); + } + typingLatch.current = null; + }, TYPING_TIMOUT); + getScheduler().add(typingLatch.current); + } + const doc = vu.state.doc; const value = doc.toString(); onChange(value, vu); @@ -126,6 +144,10 @@ export function useCodeMirror(props: UseCodeMirror) { view.destroy(); setView(undefined); } + if (typingLatch.current) { + typingLatch.current.cancel(); + typingLatch.current = null; + } }, [view], ); @@ -165,10 +187,22 @@ export function useCodeMirror(props: UseCodeMirror) { } const currentValue = view ? view.state.doc.toString() : ''; if (view && value !== currentValue) { - view.dispatch({ - changes: { from: 0, to: currentValue.length, insert: value || '' }, - annotations: [External.of(true)], - }); + const isTyping = typingLatch.current && !typingLatch.current.isDone; + + const forceUpdate = () => { + if (view && value !== view.state.doc.toString()) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.toString().length, insert: value || '' }, + annotations: [External.of(true)], + }); + } + }; + + if (!isTyping) { + forceUpdate(); + } else { + pendingUpdate.current = forceUpdate; + } } }, [value, view]);