Skip to content

Commit 5ba732c

Browse files
authored
fix: avoid random updates to view while the user is typing (#739) (#694 #199 #700)
Arize-ai/phoenix#6218
1 parent 642a9c8 commit 5ba732c

File tree

2 files changed

+136
-4
lines changed

2 files changed

+136
-4
lines changed

core/src/timeoutLatch.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Setting / Unsetting timeouts for every keystroke was a significant overhead
2+
// Inspired from https://github.com/iostreamer-X/timeout-latch
3+
4+
export class TimeoutLatch {
5+
private timeLeftMS: number;
6+
private timeoutMS: number;
7+
private isCancelled = false;
8+
private isTimeExhausted = false;
9+
private callbacks: Function[] = [];
10+
11+
constructor(callback: Function, timeoutMS: number) {
12+
this.timeLeftMS = timeoutMS;
13+
this.timeoutMS = timeoutMS;
14+
this.callbacks.push(callback);
15+
}
16+
17+
tick(): void {
18+
if (!this.isCancelled && !this.isTimeExhausted) {
19+
this.timeLeftMS--;
20+
if (this.timeLeftMS <= 0) {
21+
this.isTimeExhausted = true;
22+
const callbacks = this.callbacks.slice();
23+
this.callbacks.length = 0;
24+
callbacks.forEach((callback) => {
25+
try {
26+
callback();
27+
} catch (error) {
28+
console.error('TimeoutLatch callback error:', error);
29+
}
30+
});
31+
}
32+
}
33+
}
34+
35+
cancel(): void {
36+
this.isCancelled = true;
37+
this.callbacks.length = 0;
38+
}
39+
40+
reset(): void {
41+
this.timeLeftMS = this.timeoutMS;
42+
this.isCancelled = false;
43+
this.isTimeExhausted = false;
44+
}
45+
46+
get isDone(): boolean {
47+
return this.isCancelled || this.isTimeExhausted;
48+
}
49+
}
50+
51+
class Scheduler {
52+
private interval: NodeJS.Timeout | null = null;
53+
private latches = new Set<TimeoutLatch>();
54+
55+
add(latch: TimeoutLatch): void {
56+
this.latches.add(latch);
57+
this.start();
58+
}
59+
60+
remove(latch: TimeoutLatch): void {
61+
this.latches.delete(latch);
62+
if (this.latches.size === 0) {
63+
this.stop();
64+
}
65+
}
66+
67+
private start(): void {
68+
if (this.interval === null) {
69+
this.interval = setInterval(() => {
70+
this.latches.forEach((latch) => {
71+
latch.tick();
72+
if (latch.isDone) {
73+
this.remove(latch);
74+
}
75+
});
76+
}, 1);
77+
}
78+
}
79+
80+
private stop(): void {
81+
if (this.interval !== null) {
82+
clearInterval(this.interval);
83+
this.interval = null;
84+
}
85+
}
86+
}
87+
88+
let globalScheduler: Scheduler | null = null;
89+
90+
export const getScheduler = (): Scheduler => {
91+
if (typeof window === 'undefined') {
92+
return new Scheduler();
93+
}
94+
if (!globalScheduler) {
95+
globalScheduler = new Scheduler();
96+
}
97+
return globalScheduler;
98+
};

core/src/useCodeMirror.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { EditorView, type ViewUpdate } from '@codemirror/view';
44
import { getDefaultExtensions } from './getDefaultExtensions';
55
import { getStatistics } from './utils';
66
import { type ReactCodeMirrorProps } from '.';
7+
import { TimeoutLatch, getScheduler } from './timeoutLatch';
78

89
const External = Annotation.define<boolean>();
10+
const TYPING_TIMOUT = 200; // ms
911

1012
export interface UseCodeMirror extends ReactCodeMirrorProps {
1113
container?: HTMLDivElement | null;
@@ -41,6 +43,8 @@ export function useCodeMirror(props: UseCodeMirror) {
4143
const [container, setContainer] = useState<HTMLDivElement | null>();
4244
const [view, setView] = useState<EditorView>();
4345
const [state, setState] = useState<EditorState>();
46+
const typingLatch = useState<{ current: TimeoutLatch | null }>(() => ({ current: null }))[0];
47+
const pendingUpdate = useState<{ current: (() => void) | null }>(() => ({ current: null }))[0];
4448
const defaultThemeOption = EditorView.theme({
4549
'&': {
4650
height,
@@ -62,6 +66,20 @@ export function useCodeMirror(props: UseCodeMirror) {
6266
// If transaction is market as remote we don't have to call `onChange` handler again
6367
!vu.transactions.some((tr) => tr.annotation(External))
6468
) {
69+
if (typingLatch.current) {
70+
typingLatch.current.reset();
71+
} else {
72+
typingLatch.current = new TimeoutLatch(() => {
73+
if (pendingUpdate.current) {
74+
const forceUpdate = pendingUpdate.current;
75+
pendingUpdate.current = null;
76+
forceUpdate();
77+
}
78+
typingLatch.current = null;
79+
}, TYPING_TIMOUT);
80+
getScheduler().add(typingLatch.current);
81+
}
82+
6583
const doc = vu.state.doc;
6684
const value = doc.toString();
6785
onChange(value, vu);
@@ -126,6 +144,10 @@ export function useCodeMirror(props: UseCodeMirror) {
126144
view.destroy();
127145
setView(undefined);
128146
}
147+
if (typingLatch.current) {
148+
typingLatch.current.cancel();
149+
typingLatch.current = null;
150+
}
129151
},
130152
[view],
131153
);
@@ -165,10 +187,22 @@ export function useCodeMirror(props: UseCodeMirror) {
165187
}
166188
const currentValue = view ? view.state.doc.toString() : '';
167189
if (view && value !== currentValue) {
168-
view.dispatch({
169-
changes: { from: 0, to: currentValue.length, insert: value || '' },
170-
annotations: [External.of(true)],
171-
});
190+
const isTyping = typingLatch.current && !typingLatch.current.isDone;
191+
192+
const forceUpdate = () => {
193+
if (view && value !== view.state.doc.toString()) {
194+
view.dispatch({
195+
changes: { from: 0, to: view.state.doc.toString().length, insert: value || '' },
196+
annotations: [External.of(true)],
197+
});
198+
}
199+
};
200+
201+
if (!isTyping) {
202+
forceUpdate();
203+
} else {
204+
pendingUpdate.current = forceUpdate;
205+
}
172206
}
173207
}, [value, view]);
174208

0 commit comments

Comments
 (0)