From 86b20fd474376b41248dbdcfd781533b2069532e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Mar 2026 18:07:29 +0400 Subject: [PATCH] Abort search requests when clearing search box Also adds abort support for Photon queries --- app/components/search-box.gjs | 6 ++++ app/services/osm.js | 7 ++++ app/services/photon.js | 20 ++++++++++- tests/acceptance/search-loading-test.js | 47 ++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs index 4e506b6..c5b578e 100644 --- a/app/components/search-box.gjs +++ b/app/components/search-box.gjs @@ -12,6 +12,7 @@ import { eq, or } from 'ember-truth-helpers'; export default class SearchBoxComponent extends Component { @service photon; + @service osm; @service router; @service mapUi; @service map; // Assuming we might need map context, but mostly we use router @@ -178,6 +179,11 @@ export default class SearchBoxComponent extends Component { @action clear() { + this.searchTask.cancelAll(); + this.mapUi.stopLoading(); + this.osm.cancelAll(); + this.photon.cancelAll(); + this.query = ''; this.results = []; if (this.args.onQueryChange) { diff --git a/app/services/osm.js b/app/services/osm.js index 1c111f5..9132a2c 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -9,6 +9,13 @@ export default class OsmService extends Service { cachedResults = null; lastQueryKey = null; + cancelAll() { + if (this.controller) { + this.controller.abort(); + this.controller = null; + } + } + async getNearbyPois(lat, lon, radius = 50) { const queryKey = `${lat},${lon},${radius}`; diff --git a/app/services/photon.js b/app/services/photon.js index b17ae66..d9893a1 100644 --- a/app/services/photon.js +++ b/app/services/photon.js @@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text'; export default class PhotonService extends Service { @service settings; + controller = null; + + cancelAll() { + if (this.controller) { + this.controller.abort(); + this.controller = null; + } + } + get baseUrl() { return this.settings.photonApi; } @@ -12,6 +21,12 @@ export default class PhotonService extends Service { async search(query, lat, lon, limit = 10) { if (!query || query.length < 2) return []; + if (this.controller) { + this.controller.abort(); + } + this.controller = new AbortController(); + const signal = this.controller.signal; + const params = new URLSearchParams({ q: query, limit: String(limit), @@ -25,7 +40,7 @@ export default class PhotonService extends Service { const url = `${this.baseUrl}?${params.toString()}`; try { - const res = await this.fetchWithRetry(url); + const res = await this.fetchWithRetry(url, { signal }); if (!res.ok) { throw new Error(`Photon request failed with status ${res.status}`); } @@ -35,6 +50,9 @@ export default class PhotonService extends Service { return data.features.map((f) => this.normalizeFeature(f)); } catch (e) { + if (e.name === 'AbortError') { + return []; + } console.error('Photon search error:', e); // Return empty array on error so UI doesn't break return []; diff --git a/tests/acceptance/search-loading-test.js b/tests/acceptance/search-loading-test.js index 6f15b39..c64b478 100644 --- a/tests/acceptance/search-loading-test.js +++ b/tests/acceptance/search-loading-test.js @@ -1,10 +1,18 @@ import { module, test } from 'qunit'; -import { visit } from '@ember/test-helpers'; +import { + visit, + click, + fillIn, + triggerEvent, + currentURL, +} from '@ember/test-helpers'; import { setupApplicationTest } from 'marco/tests/helpers'; import Service from '@ember/service'; import { Promise } from 'rsvp'; class MockPhotonService extends Service { + cancelAll() {} + async search(query) { // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 50)); @@ -24,6 +32,8 @@ class MockPhotonService extends Service { } class MockOsmService extends Service { + cancelAll() {} + async getCategoryPois(bounds, category) { await new Promise((resolve) => setTimeout(resolve, 50)); if (category === 'slow_category') { @@ -94,4 +104,39 @@ module('Acceptance | search loading', function (hooks) { 'Loading state is NOT set for nearby search' ); }); + + test('clearing search stops loading indicator', async function (assert) { + const mapUi = this.owner.lookup('service:map-ui'); + + // 1. Start from index + await visit('/'); + + // 2. Type "slow" to trigger autocomplete (which is async) + await fillIn('.search-input', 'slow'); + + // 3. Submit search to trigger route loading + click('.search-submit-btn'); // Intentionally no await to not block on transition + + // Wait for loading state to activate + await new Promise((r) => setTimeout(r, 100)); + + assert.deepEqual( + mapUi.loadingState, + { type: 'text', value: 'slow' }, + 'Loading state is set' + ); + + // 4. Click the clear button (should be visible since input has value) + await click('.search-clear-btn'); + + // Verify loading state is cleared immediately + assert.strictEqual( + mapUi.loadingState, + null, + 'Loading state is cleared immediately after clicking clear' + ); + + // Verify we are back on index (or at least query is gone) + assert.strictEqual(currentURL(), '/', 'Navigated to index'); + }); });