diff --git a/app/javascript/controllers/color_icon_picker_controller.js b/app/javascript/controllers/color_icon_picker_controller.js index 55138dd4a..a208ba969 100644 --- a/app/javascript/controllers/color_icon_picker_controller.js +++ b/app/javascript/controllers/color_icon_picker_controller.js @@ -22,31 +22,42 @@ export default class extends Controller { presetColors: Array, }; - initialize() { - this.pickerBtnTarget.addEventListener("click", () => { - this.showPaletteSection(); - }); - - this.colorInputTarget.addEventListener("input", (e) => { - this.picker.setColor(e.target.value); - }); - - this.detailsTarget.addEventListener("toggle", (e) => { + connect() { + // Bound references stored on the instance so disconnect() can remove + // them. Without this, every Turbo navigation that re-renders the + // picker stacks another listener on the same node. + this._onPickerBtnClick = () => this.showPaletteSection(); + this._onColorInputInput = (e) => this.picker?.setColor(e.target.value); + this._onDetailsToggle = (e) => { if (!this.colorInputTarget.checkValidity()) { e.preventDefault(); this.colorInputTarget.reportValidity(); e.target.open = true; } - this.updatePopupPosition() - }); + this.updatePopupPosition(); + }; + + this.pickerBtnTarget.addEventListener("click", this._onPickerBtnClick); + this.colorInputTarget.addEventListener("input", this._onColorInputInput); + this.detailsTarget.addEventListener("toggle", this._onDetailsToggle); + document.addEventListener("mousedown", this.handleOutsideClick); this.selectedIcon = null; if (!this.presetColorsValue.includes(this.colorInputTarget.value)) { this.colorPickerRadioBtnTarget.checked = true; } + } - document.addEventListener("mousedown", this.handleOutsideClick); + disconnect() { + this.pickerBtnTarget.removeEventListener("click", this._onPickerBtnClick); + this.colorInputTarget.removeEventListener("input", this._onColorInputInput); + this.detailsTarget.removeEventListener("toggle", this._onDetailsToggle); + document.removeEventListener("mousedown", this.handleOutsideClick); + if (this.picker) { + this.picker.destroyAndRemove(); + this.picker = null; + } } initPicker() { diff --git a/app/javascript/controllers/goals_filter_controller.js b/app/javascript/controllers/goals_filter_controller.js index b2479a440..5e68db6f8 100644 --- a/app/javascript/controllers/goals_filter_controller.js +++ b/app/javascript/controllers/goals_filter_controller.js @@ -32,6 +32,10 @@ export default class extends Controller { } } + disconnect() { + clearTimeout(this._urlSyncTimer); + } + filter() { const query = this.hasInputTarget ? this.inputTarget.value.toLocaleLowerCase().trim() @@ -60,7 +64,16 @@ export default class extends Controller { } this.updateEmptyState(visible, query, active); - this.#syncUrl(); + this.#scheduleUrlSync(); + } + + // Debounced wrapper. Firing replaceState on every keystroke is wasteful + // and produced visible jank on slow CPUs; deferring 200 ms collapses a + // typing burst into a single URL update without losing back-button + // fidelity (replaceState doesn't create history entries anyway). + #scheduleUrlSync() { + clearTimeout(this._urlSyncTimer); + this._urlSyncTimer = setTimeout(() => this.#syncUrl(), 200); } #hydrateFromUrl() {