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: This is somewhat the opposite of and the acknowledgement that computing additional text edits for all items upfront is too demanding. This issue is only about and the goal is a pragmatic solution, similar to the one that we have for . Idea accept an unresolved completion proceed with insertion as usual set up a content change listener resolve the items once resolved check if additional edits overlap with "concurrent" changes. when they overlap, do nothing otherwise proceed
The first version for this is now merged to master, it's a pragmatic approach that's better then not resolving but worst than (costly) upfront resolving. Items to followup on [ ] telemetry and follow up issues [ ] - TS issue for having incomplete items [ ] [ ] java for returning very few (resolved) completions, e.g doing the right thing
/cc - My understanding is that Java resolves all items upfront, e.g compute additional text edits during "provide", and that it is quite expensive and therefore Java only returns a small amount of items. Is that correct? Could Java return results faster when additional text edits are computed during resolve (and therefore only for selected items)?
Yes. I think so! is the dev who was working on Java completion. I believe he can provide more insights toward this topic. // cc
this is exactly the behavior that used to have, ie compute all the completion items first (fast), then the text edits were computed during resolveCompletion. We were asked to not do that as and changed to return a small subset of completion items (with incomplete=true), with full text edits computed upfront. The user experience is definitely not as good as with our initial implementation.
To be clear, computing any Java textedit upfront is costly when returning 100+ items (and we can return several thousands items). We'd prefer if the async insertion of textedits covered all textedits, not just the additional ones.
The requirements discussed in are still true and there are no plans to change that. There is (which might be derived from ) and . The former () is inserted at the cursor and must be inserted synchronous to accepting a completion (we cannot block typing while waiting for that). The latter () is meant for things like import-statements and we now want to relax that, e.g it can come late and we try to apply those edits despite the user having typed more.