Skip to content

Commit f1ba47f

Browse files
committed
perf(multi-select): avoid double re-sort for selectionFeedback="top"
1 parent dcf0c7e commit f1ba47f

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

src/MultiSelect/MultiSelect.svelte

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@
244244
let isInitialRender = true;
245245
let listScrollTop = 0;
246246
let prevOpen = false;
247+
let internalSelectedIdsRef = selectedIds;
247248
248249
/**
249250
* @type {(data: { key: "field" | "selection"; ref: HTMLDivElement }) => void}
@@ -332,6 +333,14 @@
332333
333334
sortedItems = [...sortedItems];
334335
}
336+
337+
if (selectionFeedback === "top") {
338+
selectedIds = sortedItems
339+
.filter((si) => si.checked && !si.isSelectAll)
340+
.map((si) => si.id);
341+
internalSelectedIdsRef = selectedIds;
342+
sortedItems = sort();
343+
}
335344
}
336345
337346
afterUpdate(() => {
@@ -340,6 +349,7 @@
340349
selectedIds = checked
341350
.filter((item) => !item.isSelectAll)
342351
.map(({ id }) => id);
352+
internalSelectedIdsRef = selectedIds;
343353
if (!isInitialRender) {
344354
dispatch("select", {
345355
selectedIds,
@@ -469,9 +479,10 @@
469479
$: ariaLabel = $$props["aria-label"] ?? "Choose an item";
470480
$: if (
471481
selectedIds &&
472-
(selectionFeedback === "top" ||
482+
((selectionFeedback === "top" && selectedIds !== internalSelectedIdsRef) ||
473483
(selectionFeedback === "top-after-reopen" && open === false))
474484
) {
485+
internalSelectedIdsRef = selectedIds;
475486
sortedItems = sort();
476487
}
477488
$: sortedItemsById = new Map(sortedItems.map((item) => [item.id, item]));

tests/MultiSelect/MultiSelect.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,67 @@ describe("MultiSelect", () => {
590590
expect(nthRenderedOptionText(0)).toBe("A");
591591
});
592592

593+
it("does not double re-sort when toggling with selectionFeedback: top", async () => {
594+
let sortCallCount = 0;
595+
596+
// Wrap sortItem to count how many comparisons occur per toggle.
597+
const sortItem = (a: { text: string }, b: { text: string }) => {
598+
sortCallCount++;
599+
return a.text.localeCompare(b.text);
600+
};
601+
602+
render(MultiSelect, {
603+
props: {
604+
items: [
605+
{ id: "1", text: "A" },
606+
{ id: "2", text: "B" },
607+
{ id: "3", text: "C" },
608+
{ id: "4", text: "D" },
609+
{ id: "5", text: "E" },
610+
],
611+
selectionFeedback: "top",
612+
sortItem,
613+
},
614+
});
615+
616+
await openMenu();
617+
sortCallCount = 0;
618+
619+
// Toggle two items so both checked and unchecked partitions get sorted.
620+
await toggleOption("C");
621+
await toggleOption("E");
622+
const callsForTwoToggles = sortCallCount;
623+
624+
// With 5 items and 2 checked, a single sort() call produces
625+
// at most ~10 comparisons (two small partitions).
626+
// A double re-sort per toggle would roughly double this count.
627+
// Use a generous upper bound for a single-pass sort per toggle.
628+
expect(callsForTwoToggles).toBeLessThanOrEqual(20);
629+
expect(callsForTwoToggles).toBeGreaterThan(0);
630+
});
631+
632+
it("re-sorts when selectedIds changes externally with selectionFeedback: top", async () => {
633+
const { rerender } = render(MultiSelect, {
634+
props: {
635+
items: [
636+
{ id: "3", text: "C" },
637+
{ id: "1", text: "A" },
638+
{ id: "2", text: "B" },
639+
],
640+
selectionFeedback: "top",
641+
},
642+
});
643+
644+
await openMenu();
645+
expect(nthRenderedOptionText(0)).toBe("A");
646+
647+
// External selectedIds change should still trigger re-sort.
648+
await rerender({ selectedIds: ["3"] });
649+
await tick();
650+
651+
expect(nthRenderedOptionText(0)).toBe("C");
652+
});
653+
593654
it("sorts after reopen with selectionFeedback: top-after-reopen", async () => {
594655
render(MultiSelect, {
595656
props: {

0 commit comments

Comments
 (0)