fix(goals JS): listener cleanup on color/icon picker + debounce filter URL sync

- color_icon_picker: listeners were attached in initialize() with inline
  arrows, so they had no removable reference and no disconnect() cleanup.
  Every Turbo navigation that re-rendered the picker stacked another
  listener on the same node and left the Pickr instance alive. Move
  attachment to connect(), store bound references, and clean up in
  disconnect() (including destroyAndRemove on the Pickr).
- goals_filter: replaceState fired on every keystroke. Debounce the URL
  sync 200 ms so a typing burst collapses into a single update. The
  visible filtering stays real-time. Clear the timer in disconnect()
  so a pending sync doesn't fire on an unmounted controller.
This commit is contained in:
Guillem Arias
2026-05-18 21:03:55 +02:00
parent 79c81377ac
commit d51c88d323
2 changed files with 38 additions and 14 deletions

View File

@@ -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() {

View File

@@ -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() {