Skip to content

Commit 02f58a1

Browse files
tkirdatkirda-bisonclaude
authored
fix: coerce non-string suggestion values at the response boundary (#871)
If a server or local lookup supplies a suggestion with a non-string `value` (e.g. numeric IDs), downstream string methods such as `toLowerCase`, `replace`, `substr`, and `indexOf` throw mid-render. `verifySuggestionsFormat` now coerces any non-string `value` to a string via `String(...)` (preserving the rest of the suggestion shape via shallow copy). The coercion is also applied to the function-lookup callback path and ahead of the ajax `onSearchComplete` fire so all callbacks see a normalized `Suggestion.value: string`, matching the declared TypeScript contract. Closes #844. Co-authored-by: Tomas Kirda <tomas.kirda@bisoncommerce.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eace506 commit 02f58a1

5 files changed

Lines changed: 65 additions & 10 deletions

File tree

dist/jquery.autocomplete.esm.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,9 @@ var _Autocomplete = class _Autocomplete {
399399
const params = options.ignoreParams ? null : options.params;
400400
if (typeof options.lookup === "function") {
401401
options.lookup(q, (data) => {
402-
this.suggestions = data.suggestions;
403-
options.onSearchComplete.call(this.element, q, data.suggestions);
402+
const suggestions = this.verifySuggestionsFormat(data.suggestions);
403+
this.suggestions = suggestions;
404+
options.onSearchComplete.call(this.element, q, suggestions);
404405
this.suggest();
405406
});
406407
return;
@@ -430,6 +431,7 @@ var _Autocomplete = class _Autocomplete {
430431
this.currentRequest = $.ajax(ajaxSettings).done((data) => {
431432
this.currentRequest = null;
432433
const result = options.transformResult(data, q);
434+
result.suggestions = this.verifySuggestionsFormat(result.suggestions);
433435
options.onSearchComplete.call(this.element, q, result.suggestions);
434436
this.processResponse(result, q, cacheKey);
435437
}).fail((jqXHR, textStatus, errorThrown) => {
@@ -560,7 +562,9 @@ var _Autocomplete = class _Autocomplete {
560562
if (suggestions.length && typeof suggestions[0] === "string") {
561563
return suggestions.map((value) => ({ value, data: null }));
562564
}
563-
return suggestions;
565+
return suggestions.map(
566+
(s) => typeof s.value === "string" ? s : { ...s, value: String(s.value) }
567+
);
564568
}
565569
validateOrientation(orientation, fallback) {
566570
const normalized = (orientation || "").trim().toLowerCase();

dist/jquery.autocomplete.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,9 @@
407407
const params = options.ignoreParams ? null : options.params;
408408
if (typeof options.lookup === "function") {
409409
options.lookup(q, (data) => {
410-
this.suggestions = data.suggestions;
411-
options.onSearchComplete.call(this.element, q, data.suggestions);
410+
const suggestions = this.verifySuggestionsFormat(data.suggestions);
411+
this.suggestions = suggestions;
412+
options.onSearchComplete.call(this.element, q, suggestions);
412413
this.suggest();
413414
});
414415
return;
@@ -438,6 +439,7 @@
438439
this.currentRequest = $2.ajax(ajaxSettings).done((data) => {
439440
this.currentRequest = null;
440441
const result = options.transformResult(data, q);
442+
result.suggestions = this.verifySuggestionsFormat(result.suggestions);
441443
options.onSearchComplete.call(this.element, q, result.suggestions);
442444
this.processResponse(result, q, cacheKey);
443445
}).fail((jqXHR, textStatus, errorThrown) => {
@@ -568,7 +570,9 @@
568570
if (suggestions.length && typeof suggestions[0] === "string") {
569571
return suggestions.map((value) => ({ value, data: null }));
570572
}
571-
return suggestions;
573+
return suggestions.map(
574+
(s) => typeof s.value === "string" ? s : { ...s, value: String(s.value) }
575+
);
572576
}
573577
validateOrientation(orientation, fallback) {
574578
const normalized = (orientation || "").trim().toLowerCase();

dist/jquery.autocomplete.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Autocomplete.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,11 @@ export class Autocomplete {
415415

416416
if (typeof options.lookup === "function") {
417417
(options.lookup as LookupCallback)(q, (data) => {
418-
this.suggestions = data.suggestions;
418+
const suggestions = this.verifySuggestionsFormat(data.suggestions);
419+
this.suggestions = suggestions;
419420
// Fire onSearchComplete before suggest() so consumers see
420421
// "search complete" before any auto-select fires onSelect.
421-
options.onSearchComplete.call(this.element, q, data.suggestions);
422+
options.onSearchComplete.call(this.element, q, suggestions);
422423
this.suggest();
423424
});
424425
return;
@@ -453,6 +454,7 @@ export class Autocomplete {
453454
.done((data) => {
454455
this.currentRequest = null;
455456
const result = options.transformResult(data, q);
457+
result.suggestions = this.verifySuggestionsFormat(result.suggestions);
456458
options.onSearchComplete.call(this.element, q, result.suggestions);
457459
this.processResponse(result, q, cacheKey!);
458460
})
@@ -622,7 +624,11 @@ export class Autocomplete {
622624
if (suggestions.length && typeof suggestions[0] === "string") {
623625
return (suggestions as string[]).map((value) => ({ value, data: null }));
624626
}
625-
return suggestions as Suggestion[];
627+
// Coerce non-string `value` so downstream string methods (toLowerCase,
628+
// replace, substr, indexOf) don't throw on numeric or other types.
629+
return (suggestions as Suggestion[]).map((s) =>
630+
typeof s.value === "string" ? s : { ...s, value: String(s.value) }
631+
);
626632
}
627633

628634
validateOrientation(orientation: string | undefined, fallback: Orientation): Orientation {

test/autocomplete.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,47 @@ describe("Autocomplete", () => {
708708
});
709709
});
710710

711+
describe("Autocomplete non-string suggestion values", () => {
712+
afterEach(() => {
713+
$(".autocomplete-suggestions").remove();
714+
});
715+
716+
it("coerces numeric value from a local lookup so render does not throw", () => {
717+
const input = document.createElement("input");
718+
const autocomplete = new $.Autocomplete(input, {
719+
lookup: [{ value: 12345, data: "n" }],
720+
triggerSelectOnValidInput: false,
721+
});
722+
723+
input.value = "1";
724+
expect(() => autocomplete.onValueChange()).not.toThrow();
725+
726+
expect(typeof autocomplete.suggestions[0].value).toBe("string");
727+
expect(autocomplete.suggestions[0].value).toBe("12345");
728+
});
729+
730+
it("coerces numeric value from a function lookup callback", () => {
731+
const input = document.createElement("input");
732+
let completedValueType;
733+
let selectedValueType;
734+
const autocomplete = new $.Autocomplete(input, {
735+
lookup: (_q, done) => done({ suggestions: [{ value: 42, data: "n" }] }),
736+
onSearchComplete: (_q, suggestions) => {
737+
completedValueType = typeof suggestions[0].value;
738+
},
739+
onSelect: (suggestion) => {
740+
selectedValueType = typeof suggestion.value;
741+
},
742+
});
743+
744+
input.value = "42";
745+
autocomplete.onValueChange();
746+
747+
expect(completedValueType).toBe("string");
748+
expect(selectedValueType).toBe("string");
749+
});
750+
});
751+
711752
describe("Autocomplete event ordering", () => {
712753
afterEach(() => {
713754
$(".autocomplete-suggestions").remove();

0 commit comments

Comments
 (0)