Debounce CompletionSource Individually

Is there a way to debounce a CompletionSource, as opposed to debouncing the entire autocomplete config?

I’m trying to implement a feature that, when the doc is an invalid SQL query, sends the doc to an API which then returns a list of possible valid SQL queries.

However, I want to only call that API every 350ms after the user is done typing, and I don’t want to slow down other normal autocompletes (like schema or keywords).

I’ve tried the following, but the autocomplete seems to show past results.

import debounce from "lodash/debounce"

SQL.language.data.of({
  autocomplete: debounce(
    async (context) => {} // logic here
    350
  ),
})

This might be because debounce doesn’t work 100% with async functions; surprisingly hard to implement in user-land, even more so if AbortController is needed to cancel a fetch.

Edit: Realized CodeMirror Autocomplete debounce affects the rate at which the debounced function is called, i.e. cm_debounce(lodash_debounce(f)), which leads to an imperfect debounce time.

Might have to be solved inside of CodeMirror then (?), or the lodash debounce be based on EditorView docChanged updates (and not autocomplete).

You’d want to use your own debounce rather than lodash, so you can cancel the pending calls when the request is aborted and make sure the last given completion context is passed to the function, but I don’t see a reason why debouncing at this level wouldn’t work.

You’d want to use your own debounce rather than lodash, so you can cancel the pending calls when the request is aborted

I tried reading the autocomplete source code to understand when async completion sources are called again.

If I understood correctly, we can cancel pending calls by resolving promises will null; and then clean up those calls by using context.addEventListener. After resolved promises return null, the async completion source will be called with context again.


Is there a way to “cancel” a completion source to return null early whenever there is an editor view update?

Doesn’t seem like we can rely on the debounced function canceling previous calls, since its not called unless it exceeds minAbortTime.

No, you use these abort events to detect when the editor cancels the request. There’s no need for you to actively cancel the request—as long as the editor didn’t abort it, its result will be useful. So you’d wire up that event to call clearTimeout on your debouncer to avoid sending off superfluous requests to the server.

Should the context abort event be the only thing that clears timeout?

I’ve tried the following implementation of debounce, but a fetch network call will happen every 1000ms regardless.

It seems that the completion function will be called every 1000ms if an async completion source has not resolved yet.

So if a user is continuously typing even with a 300ms debounce, a completion source will occur every 1000ms / behaves incorrectly as a throttle instead.

If the async completion is resolved immediately, then the completion function is called per keystroke; the debounced function works here but completion results wouldn’t show as it’s already been resolved.

function debounceCompletion(source, delay) {
  let timeoutId = null;
  let currentContext = null;

  return (context) => {
    // This gets called every 1000ms, as opposed to each keypress.
    console.timeEnd("getting completion");
    console.time("getting completion");
    if (timeoutId !== null) {
      clearTimeout(timeoutId);
    }
    currentContext = context;
    return new Promise((resolve) => {
      timeoutId = setTimeout(async () => {
        timeoutId = null;
        if (context === currentContext) {
          try {
            const result = await source(context);
            resolve(result);
          } catch (error) {
            resolve(null);
          }
        } else {
          resolve(null);
        }
      }, delay);
      context.addEventListener("abort", () => {
        if (timeoutId !== null) {
          clearTimeout(timeoutId);
          timeoutId = null;
        }
        resolve(null);
      });
    });
  };
}

https://codesandbox.io/p/sandbox/cool-tdd-kr4355?file=%2Fsrc%2Findex.js

If a user is typing less than 50 characters per second, then abort controller is never called. And because the abort controller is never called in that case, the running query with the debounced function will get called.

There’s also these lines which prevents the active.source function from being called again if there’s already running.

!this.running.some(q => q.active.source == a.source)

As a result, it’s always: debounced function called → timeout never cleared and promise resolves after api call → debounce function called → timeout never cleared and promise → …

as opposed to: debounced function called → debounced function called → timeout cleared, new timeout set → api called after delay

Looking either for a way to abort a query (which would clearTimeout) whenever doc changes and , or a way to call active.source(context) again (which would also clearTimeout) after doc changes.

This is a hacky way I got debounce to work.

With CodeMirror autocomplete, there can only be one running query / promise of the same source so passing in a debounced function doesn’t work as it’s not called again until after it resolves or times out.

So what we do is create a no-op autocomplete plugin that cancels the running query, and then defers to autocomplete to call source again.

export function debounceAutocompletion(
	language: Language,
	source: CompletionSource,
	wait: number = 500,
) {
	let currContext: CompletionContext;
	let cancel = () => {}; // no-op
	return [
		language.data.of({
			autocomplete: (context: CompletionContext) => {
				currContext = context; // Use latest context.
				cancel();
				return null;
			}
		}),
		language.data.of({
			autocomplete: (context: CompletionContext) => {
				currContext = context; // Use latest context.
				cancel();
				return new Promise(resolve => {
					const timeoutId = window.setTimeout(async () => {
						try {							
							const result = await source(currContext);
							resolve(result);
						} catch (error) {
							resolve(null);
						}
					}, wait);
					cancel = () => {
						clearTimeout(timeoutId);
						resolve(null);
					}
					context.addEventListener("abort", cancel);
				});
			}
		}),
	]
}

Usage

		debounceAutocompletion(
			lang, 
			async (context) => {
				return {
					from: 0,
					options: [
						{ label: 'hello' },
						{ label: 'world' },
					],
					filter: false,
				}
			}, 
		),

Prior art on debouncing… lot of them just use EditorView.updateListener.of.

As long as the user only types normally, your completion source is left running on the assumption that it’ll return results with a validFor field that make them usable even after the user typed more. If the typed text doesn’t match validFor the completion source will be queried again after returning. As such, this should generally already provide a reasonable ‘debounced’ behavior in most circumstances. Aborts handle when the cursor is moved away from the current position or some other even happens that makes the running query obsolete.

1 Like

Makes sense. If ActiveSource supported validFor in its updateFor method, that could work to debounce or cancel a pending request. I would prefer to cancel a fetch if its invalid, rather than waiting for the fetch to go through and then invalidating the ActiveResult afterwords.

Would an extra argument to CompletionContext.addEventListener, which allows you to indicate that you want your source to be aborted on any document change, make this easier for you?

I think either docChanged, or perhaps even passing the entire viewUpdate from completionPlugin.update could work.

language.data.of({
			autocomplete: (context: CompletionContext) => {
				return new Promise(resolve => {
					const timeoutId = window.setTimeout(async () => {
						try {							
							const result = await source(context);
							resolve(result);
						} catch (error) {
							resolve(null);
						}
					}, wait);
					const cancel = () => {
						clearTimeout(timeoutId);
						resolve(null);
					}
					context.addEventListener("abort", cancel);

					context.addEventListener("docChanged", cancel);
					context.addEventListener("viewUpdate", update => {
						if (update.docChanged) cancel();
					});
				});
			}
		}),

That should allow for custom completion cancellation logic; need to double check if completion source is called with new context after last one was cancelled.

This patch implements what I meant:

Tested that patch locally; works perfectly! :smile:

Debouncing the completion source can now be done with

export function debounceCompletionSource(source: CompletionSource, wait: number = 500) {
	return (context: CompletionContext) => {
		return new Promise(resolve => {
			const timeoutId = window.setTimeout(async () => {
				try {
					const result = await source(context);
					resolve(result);
				} catch (error) {
					resolve(null);
				}
			}, wait);
			const cancel = () => {
				clearTimeout(timeoutId);
				resolve(null);
			};
			context.addEventListener('abort', cancel, { onDocChange: true });
		});
	};
}
lang.data.of({
			autocomplete: debounceCompletionSource(async (context: CompletionContext) => {
				const doc = context.state.doc.toString();
				const prefix = doc.slice(0, context.pos);
				const suffix = doc.slice(context.pos);
				const resp = await fetch('/api/autocomplete', {
					method: 'POST',
					body: JSON.stringify({
						data: {
							prefix,
							suffix,
						},
					}),
				});
				const { completions } = (await resp.json()) as { completions: string[] };
				const options = completions.map(c => ({
					displayLabel: c,
					label: c
						.replace(RegExp(`^${escapeRegExp(prefix)}`, 'g'), '')
						.replace(RegExp(`${escapeRegExp(suffix)}$`, 'g'), ''),
					type: 'variable',
				}));
				return {
					from: context.pos,
					options: options,
					filter: false,
				};
			}, 250),
		}),