Skip to content

Commit b833a40

Browse files
Fix RangeError due to stale signature help tooltips (#1054)
It now maps the position through so you see lagged tooltips if Pyright responses are slow rather than tooltips for positions that might not be in the document at all. We also use queueMicrotask so we can be sure our changes happen before pending event handlers, which prevents dnd causing errors.
1 parent bb11599 commit b833a40

File tree

2 files changed

+94
-77
lines changed

2 files changed

+94
-77
lines changed

src/editor/codemirror/language-server/signatureHelp.ts

Lines changed: 90 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*
88
* SPDX-License-Identifier: MIT
99
*/
10-
import { StateEffect, StateField } from "@codemirror/state";
10+
import { EditorState, StateEffect, StateField } from "@codemirror/state";
1111
import {
1212
Command,
1313
EditorView,
@@ -16,7 +16,6 @@ import {
1616
logException,
1717
PluginValue,
1818
showTooltip,
19-
Tooltip,
2019
ViewPlugin,
2120
ViewUpdate,
2221
} from "@codemirror/view";
@@ -38,18 +37,32 @@ import {
3837
import { nameFromSignature, removeFullyQualifiedName } from "./names";
3938
import { offsetToPosition } from "./positions";
4039

41-
interface SignatureChangeEffect {
42-
pos: number;
43-
result: SignatureHelp | null;
44-
}
40+
export const setSignatureHelpRequestPosition = StateEffect.define<number>({});
4541

46-
export const setSignatureHelpEffect = StateEffect.define<SignatureChangeEffect>(
42+
export const setSignatureHelpResult = StateEffect.define<SignatureHelp | null>(
4743
{}
4844
);
4945

50-
interface SignatureHelpState {
51-
tooltip: Tooltip | null;
46+
class SignatureHelpState {
47+
/**
48+
* -1 for no signature help requested.
49+
*/
50+
pos: number;
51+
/**
52+
* The latest result we want to display.
53+
*
54+
* This may be out of date while we wait for async response from LSP
55+
* but we display it as it's generally useful.
56+
*/
5257
result: SignatureHelp | null;
58+
59+
constructor(pos: number, result: SignatureHelp | null) {
60+
if (result && pos === -1) {
61+
throw new Error("Invalid state");
62+
}
63+
this.pos = pos;
64+
this.result = result;
65+
}
5366
}
5467

5568
const signatureHelpToolTipBaseTheme = EditorView.baseTheme({
@@ -63,32 +76,42 @@ const signatureHelpToolTipBaseTheme = EditorView.baseTheme({
6376
},
6477
});
6578

66-
const triggerSignatureHelpRequest = async (view: EditorView): Promise<void> => {
67-
const uri = view.state.facet(uriFacet)!;
68-
const client = view.state.facet(clientFacet)!;
69-
const pos = view.state.selection.main.from;
79+
const triggerSignatureHelpRequest = async (
80+
view: EditorView,
81+
state: EditorState
82+
): Promise<void> => {
83+
const uri = state.facet(uriFacet)!;
84+
const client = state.facet(clientFacet)!;
85+
const pos = state.selection.main.from;
7086
const params: SignatureHelpParams = {
7187
textDocument: { uri },
72-
position: offsetToPosition(view.state.doc, pos),
88+
position: offsetToPosition(state.doc, pos),
7389
};
7490
try {
91+
// Must happen before other event handling that might dispatch more
92+
// changes that invalidate our position.
93+
queueMicrotask(() => {
94+
view.dispatch({
95+
effects: [setSignatureHelpRequestPosition.of(pos)],
96+
});
97+
});
7598
const result = await client.connection.sendRequest(
7699
SignatureHelpRequest.type,
77100
params
78101
);
79102
view.dispatch({
80-
effects: [setSignatureHelpEffect.of({ pos, result })],
103+
effects: [setSignatureHelpResult.of(result)],
81104
});
82105
} catch (e) {
83-
logException(view.state, e, "signature-help");
106+
logException(state, e, "signature-help");
84107
view.dispatch({
85-
effects: [setSignatureHelpEffect.of({ pos, result: null })],
108+
effects: [setSignatureHelpResult.of(null)],
86109
});
87110
}
88111
};
89112

90113
const openSignatureHelp: Command = (view: EditorView) => {
91-
triggerSignatureHelpRequest(view);
114+
triggerSignatureHelpRequest(view, view.state);
92115
return true;
93116
};
94117

@@ -98,28 +121,57 @@ export const signatureHelp = (
98121
apiReferenceMap: ApiReferenceMap
99122
) => {
100123
const signatureHelpTooltipField = StateField.define<SignatureHelpState>({
101-
create: () => ({
102-
result: null,
103-
tooltip: null,
104-
}),
124+
create: () => new SignatureHelpState(-1, null),
105125
update(state, tr) {
126+
let { pos, result } = state;
106127
for (const effect of tr.effects) {
107-
if (effect.is(setSignatureHelpEffect)) {
108-
return reduceSignatureHelpState(state, effect.value, apiReferenceMap);
128+
if (effect.is(setSignatureHelpRequestPosition)) {
129+
pos = effect.value;
130+
} else if (effect.is(setSignatureHelpResult)) {
131+
result = effect.value;
132+
if (result === null) {
133+
// No need to ask for more updates until triggered again.
134+
pos = -1;
135+
}
109136
}
110137
}
111-
return state;
138+
// Even if we just got a result, if the position has been cleared we don't want it.
139+
if (pos === -1) {
140+
result = null;
141+
}
142+
143+
pos = pos === -1 ? -1 : tr.changes.mapPos(pos);
144+
if (state.pos === pos && state.result === result) {
145+
// Avoid pointless tooltip updates. If nothing else it makes e2e tests hard.
146+
return state;
147+
}
148+
return new SignatureHelpState(pos, result);
112149
},
113-
provide: (f) => showTooltip.from(f, (val) => val.tooltip),
150+
provide: (f) =>
151+
showTooltip.from(f, (val) => {
152+
const { result, pos } = val;
153+
if (result) {
154+
return {
155+
pos,
156+
above: true,
157+
// This isn't great but the impact is really bad when it conflicts with autocomplete.
158+
// strictSide: true,
159+
create: () => {
160+
const dom = document.createElement("div");
161+
dom.className = "cm-signature-tooltip";
162+
dom.appendChild(formatSignatureHelp(result, apiReferenceMap));
163+
return { dom };
164+
},
165+
};
166+
}
167+
return null;
168+
}),
114169
});
115170

116171
const closeSignatureHelp: Command = (view: EditorView) => {
117-
if (view.state.field(signatureHelpTooltipField).tooltip) {
172+
if (view.state.field(signatureHelpTooltipField).pos !== -1) {
118173
view.dispatch({
119-
effects: setSignatureHelpEffect.of({
120-
pos: -1,
121-
result: null,
122-
}),
174+
effects: setSignatureHelpRequestPosition.of(-1),
123175
});
124176
return true;
125177
}
@@ -139,64 +191,29 @@ export const signatureHelp = (
139191
constructor(view: EditorView, private automatic: boolean) {
140192
super(view);
141193
}
142-
update({ docChanged, selectionSet, transactions }: ViewUpdate) {
194+
update(update: ViewUpdate) {
143195
if (
144-
(docChanged || selectionSet) &&
145-
this.view.state.field(signatureHelpTooltipField).tooltip
196+
(update.docChanged || update.selectionSet) &&
197+
this.view.state.field(signatureHelpTooltipField).pos !== -1
146198
) {
147-
triggerSignatureHelpRequest(this.view);
148-
} else if (this.automatic && docChanged) {
149-
const last = transactions[transactions.length - 1];
199+
triggerSignatureHelpRequest(this.view, update.state);
200+
} else if (this.automatic && update.docChanged) {
201+
const last = update.transactions[update.transactions.length - 1];
150202

151203
// This needs to trigger for autocomplete adding function parens
152204
// as well as normal user input with `closebrackets` inserting
153205
// the closing bracket.
154206
if (last.isUserEvent("input") || last.isUserEvent("dnd.drop.call")) {
155207
last.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
156208
if (inserted.sliceString(0).trim().endsWith("()")) {
157-
triggerSignatureHelpRequest(this.view);
209+
triggerSignatureHelpRequest(this.view, update.state);
158210
}
159211
});
160212
}
161213
}
162214
}
163215
}
164216

165-
const reduceSignatureHelpState = (
166-
state: SignatureHelpState,
167-
effect: SignatureChangeEffect,
168-
apiReferenceMap: ApiReferenceMap
169-
): SignatureHelpState => {
170-
if (state.tooltip && !effect.result) {
171-
return {
172-
result: null,
173-
tooltip: null,
174-
};
175-
}
176-
// It's a bit weird that we always update the position, but VS Code does this too.
177-
// I think ideally we'd have a notion of "same function call". Does the
178-
// node have a stable identity?
179-
if (effect.result) {
180-
const result = effect.result;
181-
return {
182-
result,
183-
tooltip: {
184-
pos: effect.pos,
185-
above: true,
186-
// This isn't great but the impact is really bad when it conflicts with autocomplete.
187-
// strictSide: true,
188-
create: () => {
189-
const dom = document.createElement("div");
190-
dom.className = "cm-signature-tooltip";
191-
dom.appendChild(formatSignatureHelp(result, apiReferenceMap));
192-
return { dom };
193-
},
194-
},
195-
};
196-
}
197-
return state;
198-
};
199-
200217
const formatSignatureHelp = (
201218
help: SignatureHelp,
202219
apiReferenceMap: ApiReferenceMap

src/editor/codemirror/lint/editingLine.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ export const editingLinePlugin = ViewPlugin.fromClass(
3434
if (!foundEditOnLine && doc.lineAt(fromB).number === selectionLine) {
3535
foundEditOnLine = true;
3636
clearTimeout(this.timeout);
37-
setTimeout(() => {
37+
queueMicrotask(() => {
3838
update.view.dispatch({
3939
effects: [setEditingLineEffect.of(selectionLine)],
4040
});
41-
}, 0);
41+
});
4242
this.timeout = setTimeout(() => {
4343
update.view.dispatch({
4444
effects: [setEditingLineEffect.of(undefined)],
@@ -51,11 +51,11 @@ export const editingLinePlugin = ViewPlugin.fromClass(
5151
update.state.field(editingLineState) !== selectionLine
5252
) {
5353
clearTimeout(this.timeout);
54-
setTimeout(() => {
54+
queueMicrotask(() => {
5555
update.view.dispatch({
5656
effects: [setEditingLineEffect.of(undefined)],
5757
});
58-
}, 0);
58+
});
5959
}
6060
}
6161

0 commit comments

Comments
 (0)