From 29d04e6c2d86fe946569feb6dea6b897dba369e5 Mon Sep 17 00:00:00 2001 From: Conciergerie AI Date: Thu, 3 Sep 2026 16:23:17 +0200 Subject: [PATCH 1/4] feat(search): expose facet-affecting params from useStableQueryParams --- .../src/components/Search/GlobalSearch.vue | 2 +- .../src/composables/useStableQueryParams.ts | 18 ++++- .../stable-query-params.spec.ts | 66 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests-unit/datagouv-components/stable-query-params.spec.ts diff --git a/datagouv-components/src/components/Search/GlobalSearch.vue b/datagouv-components/src/components/Search/GlobalSearch.vue index 4fa2c0034..1604f0ed0 100644 --- a/datagouv-components/src/components/Search/GlobalSearch.vue +++ b/datagouv-components/src/components/Search/GlobalSearch.vue @@ -668,7 +668,7 @@ const strategies: { [K in SearchType]: SearchStrategy } = { const resultsMap: Record = {} for (const c of props.config) { const key = configKey(c) - const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c }) + const { params } = useStableQueryParams({ ...stableParamsOptions, typeConfig: c }) resultsMap[key] = await strategies[c.class].fetch(params, initialType === key) } diff --git a/datagouv-components/src/composables/useStableQueryParams.ts b/datagouv-components/src/composables/useStableQueryParams.ts index 0063a0d86..444e9c14d 100644 --- a/datagouv-components/src/composables/useStableQueryParams.ts +++ b/datagouv-components/src/composables/useStableQueryParams.ts @@ -15,12 +15,18 @@ interface StableQueryParamsOptions { } /** - * Creates a stable ref for query params that only updates when content actually changes. + * Creates stable refs for query params that only update when content actually changes. * Applies hiddenFilters first, then user filters (which can override hiddenFilters). + * `facetParams` is the same params minus `sort` and `page`, which do not affect + * facet aggregations. */ export function useStableQueryParams(options: StableQueryParamsOptions) { const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options const stableParams = ref>({}) + // Same params minus sort and page: the signature of everything that affects + // facet aggregations. Used by useStableFacets to keep facets stable across + // sort and page changes. + const stableFacetParams = ref>({}) const buildParams = () => { const params: Record = {} @@ -108,9 +114,17 @@ export function useStableQueryParams(options: StableQueryParamsOptions) { if (JSON.stringify(newParams) !== JSON.stringify(stableParams.value)) { stableParams.value = newParams } + + const newFacetParams = { ...newParams } + delete newFacetParams.sort + delete newFacetParams.page + delete newFacetParams.page_size + if (JSON.stringify(newFacetParams) !== JSON.stringify(stableFacetParams.value)) { + stableFacetParams.value = newFacetParams + } }, { immediate: true }, ) - return stableParams + return { params: stableParams, facetParams: stableFacetParams } } diff --git a/tests-unit/datagouv-components/stable-query-params.spec.ts b/tests-unit/datagouv-components/stable-query-params.spec.ts new file mode 100644 index 000000000..35fb90c0f --- /dev/null +++ b/tests-unit/datagouv-components/stable-query-params.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { nextTick, ref } from 'vue' +import { useStableQueryParams } from '~/datagouv-components/src/composables/useStableQueryParams' +import type { SearchTypeConfig } from '~/datagouv-components/src/types/search' + +function setup() { + const q = ref('') + const sort = ref(undefined) + const page = ref(1) + const tag = ref(undefined) + const typeConfig: SearchTypeConfig = { + class: 'datasets', + basicFilters: ['tag'], + sortOptions: [{ value: '-created', label: 'Plus récents' }], + } + const { params, facetParams } = useStableQueryParams({ + typeConfig, + allFilters: { tag }, + customFilterRegistry: new Map(), + q, + sort, + page, + pageSize: 20, + }) + return { q, sort, page, tag, params, facetParams } +} + +describe('useStableQueryParams', () => { + it('excludes sort and page from facetParams', () => { + const { params, facetParams } = setup() + + expect(params.value).toEqual({ page: 1, page_size: 20 }) + expect(facetParams.value).toEqual({}) + }) + + it('keeps facetParams identity stable when only sort or page change', async () => { + const { sort, page, params, facetParams } = setup() + const before = facetParams.value + + sort.value = '-created' + await nextTick() + expect(params.value.sort).toBe('-created') + expect(facetParams.value).toBe(before) + + page.value = 2 + await nextTick() + expect(params.value.page).toBe(2) + expect(facetParams.value).toBe(before) + }) + + it('updates facetParams when a facet-affecting param changes', async () => { + const { q, tag, facetParams } = setup() + const before = facetParams.value + + tag.value = 'energy' + await nextTick() + expect(facetParams.value).toEqual({ tag: 'energy' }) + expect(facetParams.value).not.toBe(before) + + const afterTag = facetParams.value + q.value = 'test' + await nextTick() + expect(facetParams.value).toEqual({ tag: 'energy', q: 'test' }) + expect(facetParams.value).not.toBe(afterTag) + }) +}) From 44d87b8091a384b0b034cf945c664fbbebcddcd2 Mon Sep 17 00:00:00 2001 From: Conciergerie AI Date: Thu, 3 Sep 2026 16:25:40 +0200 Subject: [PATCH 2/4] feat(search): add useStableFacets composable --- .../src/composables/useStableFacets.ts | 50 +++++++++++++ .../datagouv-components/stable-facets.spec.ts | 70 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 datagouv-components/src/composables/useStableFacets.ts create mode 100644 tests-unit/datagouv-components/stable-facets.spec.ts diff --git a/datagouv-components/src/composables/useStableFacets.ts b/datagouv-components/src/composables/useStableFacets.ts new file mode 100644 index 000000000..787cbc550 --- /dev/null +++ b/datagouv-components/src/composables/useStableFacets.ts @@ -0,0 +1,50 @@ +import { computed, shallowRef, watch, type ComputedRef, type Ref } from 'vue' +import type { AsyncDataRequestStatus } from '../functions/api.types' + +interface StableFacetsOptions { + /** Latest search response for one search type (null until the first response). */ + data: Ref<{ facets: F } | null> + /** Fetch status for that response. */ + status: Ref + /** Stable params that affect facet aggregations (see useStableQueryParams). */ + facetParams: Ref> +} + +interface StableFacets { + facets: ComputedRef + loading: ComputedRef +} + +/** + * Keeps the facets of a search response stable across refetches that cannot + * change them. Facet aggregations only depend on the query and the filters, so + * a refetch triggered by a sort or page change returns the same facets: the + * cached object is kept (stable identity, no re-render and no loading flash on + * the facet filters) instead of being replaced by the new response's copy. + */ +export function useStableFacets(options: StableFacetsOptions): StableFacets { + const { data, status, facetParams } = options + const cachedFacets = shallowRef(null) + // facetParams only gets a new identity when its content changes + // (see useStableQueryParams), so !== is a content comparison. + const appliedFacetParams = shallowRef | null>(null) + + // Only watch data: when facetParams change, a fetch is in flight and the + // current data is stale, so it must not be cached under the new params. + watch(data, (results) => { + if (!results) return + if (facetParams.value !== appliedFacetParams.value) { + cachedFacets.value = results.facets + appliedFacetParams.value = facetParams.value + } + }, { immediate: true }) + + const facets = computed(() => cachedFacets.value) + + // Facets are only "loading" while a fetch that can change them is in flight. + const loading = computed(() => + status.value === 'pending' && facetParams.value !== appliedFacetParams.value, + ) + + return { facets, loading } +} diff --git a/tests-unit/datagouv-components/stable-facets.spec.ts b/tests-unit/datagouv-components/stable-facets.spec.ts new file mode 100644 index 000000000..6f4a135df --- /dev/null +++ b/tests-unit/datagouv-components/stable-facets.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { nextTick, ref, shallowRef } from 'vue' +import { useStableFacets } from '~/datagouv-components/src/composables/useStableFacets' +import type { AsyncDataRequestStatus } from '~/datagouv-components/src/functions/api.types' + +type Facets = Record + +function setup() { + // useFetch (the real data source) stores its payload in a shallowRef: facet + // objects keep their identity, which is what the identity assertions below + // rely on. A deep ref would wrap facets in reactive proxies. + const data = shallowRef<{ facets: Facets } | null>(null) + const status = ref('idle') + const facetParams = ref>({}) + return { data, status, facetParams, ...useStableFacets({ data, status, facetParams }) } +} + +describe('useStableFacets', () => { + it('exposes null facets before the first response', () => { + const { facets } = setup() + expect(facets.value).toBeNull() + }) + + it('exposes the facets of the first response', async () => { + const { data, facets } = setup() + const responseFacets = { format_family: [{ name: 'tabular', count: 3 }] } + data.value = { facets: responseFacets } + await nextTick() + expect(facets.value).toBe(responseFacets) + }) + + it('keeps the previous facets when a refetch only changed sort or page', async () => { + const { data, status, facets, loading } = setup() + const responseFacets = { format_family: [{ name: 'tabular', count: 3 }] } + data.value = { facets: responseFacets } + await nextTick() + + // facetParams identity is unchanged (only sort/page changed) + status.value = 'pending' + await nextTick() + expect(loading.value).toBe(false) + + data.value = { facets: { format_family: [{ name: 'tabular', count: 3 }] } } + status.value = 'success' + await nextTick() + expect(facets.value).toBe(responseFacets) + }) + + it('recomputes facets when facet params change', async () => { + const { data, status, facetParams, facets, loading } = setup() + const initialFacets = { badge: [{ name: 'x', count: 1 }] } + data.value = { facets: initialFacets } + await nextTick() + + // A filter change gives facetParams a new identity (see useStableQueryParams) + facetParams.value = { tag: 'energy' } + status.value = 'pending' + await nextTick() + expect(loading.value).toBe(true) + // The previous facets stay displayed while the fetch is in flight + expect(facets.value).toBe(initialFacets) + + const newFacets = { badge: [{ name: 'y', count: 2 }] } + data.value = { facets: newFacets } + status.value = 'success' + await nextTick() + expect(facets.value).toBe(newFacets) + expect(loading.value).toBe(false) + }) +}) From c3367daa243dc850db7e10246ffaed72adf2a5ae Mon Sep 17 00:00:00 2001 From: Conciergerie AI Date: Thu, 3 Sep 2026 16:29:39 +0200 Subject: [PATCH 3/4] fix(search): keep facets stable on sort and page changes --- .../src/components/Search/GlobalSearch.vue | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/datagouv-components/src/components/Search/GlobalSearch.vue b/datagouv-components/src/components/Search/GlobalSearch.vue index 1604f0ed0..1965e43fb 100644 --- a/datagouv-components/src/components/Search/GlobalSearch.vue +++ b/datagouv-components/src/components/Search/GlobalSearch.vue @@ -107,28 +107,28 @@ v-if="isEnabled('format_family')" v-model="formatFamily" :facets="getFacets('format_family')" - :loading="searchResultsStatus === 'pending'" + :loading="facetsLoading" :style="{ order: getOrder('format_family') }" /> @@ -136,14 +136,14 @@ v-if="isEnabled('badge')" v-model="badge" :facets="getFacets('badge')" - :loading="searchResultsStatus === 'pending'" + :loading="facetsLoading" :style="{ order: getOrder('badge') }" />