Code change:
assert.equal(editor.getValue(), '    let name = foo'); }); <ins> test('use additionalTextEdits sync when possible', async function () { disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos), additionalTextEdits: [{ text: 'I came sync', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }] }] }; }, async resolveCompletionItem(item) { return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'I came synchellonhallohello'); }); test('resolve additionalTextEdits async when needed', async function () { let resolveCallCount = 0; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await timeout(10); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait await timeout(20); assert.equal(editor.getValue(), 'I came latehellonhallohello'); // single undo stop editor.getModel()?.undo(); assert.equal(editor.getValue(), 'hellonhallo'); }); test('resolve additionalTextEdits async when needed (typing)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 11, 2, 11))); editor.trigger('test', 'type', { text: 'TYPING' }); assert.equal(editor.getValue(), 'hellonhallohelloTYPING'); resolve(); await timeout(10); assert.equal(editor.getValue(), 'I came latehellonhallohelloTYPING'); assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 17, 2, 17))); }); // additional edit come late and are AFTER the selection -> cancel test('resolve additionalTextEdits async when needed (simple conflict)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 6, endLineNumber: 1, endColumn: 6 } }]; return item; } })); editor.setValue(''); editor.setSelection(new Selection(1, 1, 1, 1)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hello'); assert.equal(resolveCallCount, 1); resolve(); await timeout(10); assert.equal(editor.getValue(), 'hello'); }); // additional edit come late and are AFTER the position at which the user typed -> cancelled test('resolve additionalTextEdits async when needed (conflict)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait editor.setSelection(new Selection(1, 1, 1, 1)); editor.trigger('test', 'type', { text: 'TYPING' }); assert.equal(editor.getValue(), 'TYPINGhellonhallohello'); resolve(); await timeout(10); assert.equal(editor.getValue(), 'TYPINGhellonhallohello'); assert.ok(editor.getSelection()?.equalsSelection(new Selection(1, 7, 1, 7))); }); test('resolve additionalTextEdits async when needed (cancel)', async function () { let resolve: Function[] = []; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }, { kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hallo', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { await new Promise(_resolve => resolve.push(_resolve)); item.additionalTextEdits = [{ text: 'additionalTextEdits', range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 } }]; return item; } })); editor.setValue('abc'); editor.setSelection(new Selection(1, 1, 1, 1)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(true, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'helloabc'); // next controller.acceptNextSuggestion(); // resolve additional edits (MUST be cancelled) resolve.forEach(fn => fn); resolve.length = 0; await timeout(10); // next suggestion used assert.equal(editor.getValue(), 'halloabc'); }); </ins> });

Related discussion:
text: Seems unlikely that people would want to get the main edit without the additional edits By making the operation cancelable, you can still go back to typing immediately if you so desire. I certainly wouldn't want to block the user from typing without recourse (as you know, I feel frustrated that happens currently when using Vim extensions) On the other hand, I guess it's unlikely that the additional text edits would overlap whatever the user continues typing...
I hadn't thought about auto accept characters though, that's a good point. That's something I don't have experience with. Also considering that VSCode could pop up a warning if it has to bail on inserting additional edits, it's not a big deal if it ever happens
I remember that we have some new data points regarding the time consumption in different stages of code completion in Can you share that data and see how we can tune it with this change?
For Java language server, below is a sample of call tree when triggering completion. (time value is larger than expected because of extra overhead from JProfiler) ! Total time of completion stage mainly consists of three parts: competion candidates in JDT to CompletionItems (calc textEdits, additionalTextEdits, etc.) --- calc an incomplete list of only 50 items by default (the full list can contain thousands) --- here calculating import statements costs a lot of time server-side code snippets (not much related to this topic) I did a simple experiment on java language server, with vscode-insider built on 2020-5-15. I was using spring-petclinic, which is not a big one, but with some dependencies. Type a initial letter, e.g. "O", and observe perf of completion and resolve. Do multiple times for statistics. Calculating additionalTextEdits async in resolve stage does decrease the time cost a lot. textEdits and additionalTextEdits are calculated during completion stage. completion: ~150 ms resolve(avg of top 10 items): ~10 ms Now I simply move a bunch of textedit calculation from "completion" stage into "resolve" stage. completion: ~100 ms resolve(avg of top 10 items): ~17 ms !
I just realized that can return a thenable, but I assume that would delay the appearance of the entire menu?
Closing as we haven't seen issues with the new pragmatic approach and the plan is to stick with it.