Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
All notable changes to this project are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [4.0.3] - 2026-06-17

### Fixed
- **`setSource()` now invalidates rendered results.** Swapping the source while
the control was closed left the previous source's options in `this.results`;
the focus path's `!results.length` guard then skipped the re-search, so the
next open showed stale options (e.g. a dependent Country → Region dropdown
never refreshed). `setSource()` now clears `results`/`_total`/`activeIndex`
(matching `setScope()`), so the next open reflects the new source. The current
selection/value is left untouched. The `setScope({})` workaround is no longer
needed.

## [4.0.2] - 2026-06-17

### Docs
Expand Down
9 changes: 8 additions & 1 deletion dist/liveselect.js
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,14 @@
/** Swap the data source (e.g. after pushing a new item to an array). */
LiveSelect.prototype.setSource = function (source) {
this.opts.source = source;
this._cache = {}; // results from the old source are no longer valid
// Invalidate everything rendered from the old source — otherwise a closed
// control keeps its stale results, and _onFocus's `!results.length` guard
// then skips the re-search on the next open. (The selection/value is left
// untouched; only the available options change.)
this.results = [];
this._total = null;
this.activeIndex = -1;
this._cache = {};
if (this.isOpen) this._runSearch();
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@michaeljfalk/liveselect",
"version": "4.0.2",
"version": "4.0.3",
"publishConfig": {
"access": "public"
},
Expand Down
38 changes: 38 additions & 0 deletions test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,41 @@ test('enhance(<select multiple>) upgrades to multi and reflects back', { skip: !
assert.deepEqual(selected, ['go', 'js', 'py'], 'reflected into the native <select multiple>');
dd.destroy();
});

test('setSource() while closed refreshes the menu on next open (no stale options)', { skip: !domAvailable }, () => {
const host = mount();
const A = [{ value: 'a1', label: 'Apple' }, { value: 'a2', label: 'Apricot' }];
const B = [{ value: 'b1', label: 'Banana' }, { value: 'b2', label: 'Blueberry' }];
const dd = new LiveSelect(host, { source: A });

// Open once so results populate from A, then close.
dd.query = ''; dd.isOpen = true; dd._runSearch(); dd._renderMenu();
assert.equal(dd.results.length, 2);
dd.close();

// Swap the source while closed.
dd.setSource(B);
assert.deepEqual(dd.results, [], 'rendered results invalidated immediately');

// Re-open via the focus path → must reflect B, not stale A.
dd._onFocus();
dd._renderMenu();
const labels = Array.from(dd.menu.querySelectorAll('.liveselect__opt-label')).map((s) => s.textContent);
assert.deepEqual(labels, ['Banana', 'Blueberry'], 'menu lists the new source');
dd.destroy();
});

test('setSource() leaves the current selection/value intact', { skip: !domAvailable }, () => {
const host = mount();
const dd = new LiveSelect(host, {
name: 'fruit',
source: [{ value: 'a1', label: 'Apple' }],
});
dd._select({ value: 'a1', label: 'Apple', sublabel: '' });
assert.equal(dd.getValue(), 'a1');

dd.setSource([{ value: 'b1', label: 'Banana' }]); // swap options only
assert.equal(dd.getValue(), 'a1', 'selected value survives the source swap');
assert.equal(host.querySelector('input[type=hidden][name=fruit]').value, 'a1');
dd.destroy();
});