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
),
})
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);
});
});
};
}
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.
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.
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.
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.
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?
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.