fix(front) : usePaginatedList — garde anti-reponse periemee, expose error, durcit buildQuery
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m25s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m14s

- ajoute un jeton de sequence dans fetch() : ignore les reponses
  arrivees apres une requete plus recente (race page/tri/filtres sur
  reseau lent)
- expose une ref error pour distinguer liste vide d'un echec reseau/403
  (isEmpty ne suffit pas a lever l'ambiguite)
- buildQuery : cles reservees (page/itemsPerPage/order) assignees en
  dernier pour qu'un filtre homonyme ne les ecrase pas
- corrige le commentaire trompeur sur extraQuery (snapshot statique)
- nettoie le test hors-borne qui testait en realite le cas standard +
  ajoute tests error/reset-error/race
This commit is contained in:
2026-05-31 13:13:25 +02:00
parent 43c3220873
commit f770812b7e
2 changed files with 104 additions and 32 deletions
@@ -231,8 +231,11 @@ describe('usePaginatedList', () => {
expect(q['order[name]']).toBeUndefined()
})
it('page hors borne apres filtre retombe sur la derniere page valide', async () => {
// 1er fetch : page 1 sur une grosse liste
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
// setFilters remet toujours page=1 avant de refetcher : ce n'est
// donc PAS le chemin de retry hors-borne (couvert par le test
// suivant via un refetch a page constante). On verifie juste le
// reset de page ici.
mockResponse([], 50) // 5 pages
const list = usePaginatedList({ url: '/users' })
await list.fetch()
@@ -240,21 +243,11 @@ describe('usePaginatedList', () => {
await list.goToPage(5)
expect(list.currentPage.value).toBe(5)
// Application d'un filtre : la nouvelle reponse a 12 items
// (donc 2 pages) mais on demande page=5 → l'API renvoie member=[]
// et le composable doit refetcher sur page=2.
mockApiGet.mockReset()
mockApiGet
// 1er appel : page=5 alors qu'il ne reste que 2 pages
.mockResolvedValueOnce({ member: [], totalItems: 12 })
// 2eme appel : refetch automatique sur page=2
.mockResolvedValueOnce({ member: [{ id: 1 }, { id: 2 }], totalItems: 12 })
mockResponse([{ id: 1 }, { id: 2 }], 12)
await list.setFilters({ active: true } as never)
// setFilters reset page a 1 → c'est le cas standard, pas le hors borne.
// Pour declencher le cas hors borne, on doit forcer la page > totalPages.
expect(list.currentPage.value).toBe(1)
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
})
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
@@ -332,7 +325,8 @@ describe('usePaginatedList', () => {
})
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
mockApiGet.mockRejectedValueOnce(new Error('boom'))
const boom = new Error('boom')
mockApiGet.mockRejectedValueOnce(boom)
const list = usePaginatedList({ url: '/users' })
await expect(list.fetch()).resolves.toBeUndefined()
@@ -341,6 +335,47 @@ describe('usePaginatedList', () => {
expect(list.loading.value).toBe(false)
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
expect(list.isEmpty.value).toBe(true)
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
expect(list.error.value).toBe(boom)
})
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
mockApiGet.mockRejectedValueOnce(new Error('boom'))
const list = usePaginatedList({ url: '/users' })
await list.fetch()
expect(list.error.value).toBeInstanceOf(Error)
mockResponse([{ id: 1 }], 1)
await list.fetch()
expect(list.error.value).toBeNull()
expect(list.items.value).toEqual([{ id: 1 }])
})
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
// foi quel que soit l'ordre d'arrivee reseau.
const list = usePaginatedList<{ id: number }>({ url: '/users' })
let resolveSlow!: (v: unknown) => void
const slow = new Promise((r) => { resolveSlow = r })
// 1er appel : reponse lente (en vol).
mockApiGet.mockReturnValueOnce(slow)
// 2eme appel : reponse immediate avec des donnees plus fraiches.
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
const p1 = list.fetch() // token 1, en vol
const p2 = list.fetch() // token 2, resout tout de suite
await p2
expect(list.items.value).toEqual([{ id: 2 }])
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
await p1
expect(list.items.value).toEqual([{ id: 2 }])
// Le spinner reste eteint (la requete recente l'avait deja coupe).
expect(list.loading.value).toBe(false)
})
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
+54 -17
View File
@@ -63,8 +63,11 @@ export interface UsePaginatedListOptions<F extends PaginatedListFilters = Pagina
defaultSort?: SortSpec | null
/**
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
* `groups[]=foo`) injectes a chaque requete. Reactifs si une ref / computed
* est fournie via `refresh()` apres mutation.
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
* pas comme valeur) — pour un extra reactif, muter les filtres via
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
*/
extraQuery?: Record<string, unknown>
}
@@ -84,6 +87,14 @@ export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = Pagi
totalPages: Ref<number>
/** Indicateur de chargement (vrai pendant `fetch()`). */
loading: Ref<boolean>
/**
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
* Permet a la page de distinguer « liste reellement vide » d'un echec
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
* tombe a 0 item dans les deux situations). La page decide de l'UX
* (bandeau, bouton reessayer) — le composable ne toaste pas.
*/
error: Ref<unknown | null>
/** Vrai apres au moins un fetch reussi avec 0 item. */
isEmpty: Ref<boolean>
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
@@ -150,6 +161,13 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
const itemsPerPage = ref(defaultItemsPerPage)
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
const loading = ref(false)
const error = ref<unknown | null>(null)
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
// le jeton n'est plus le dernier est ignoree (protection contre les
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
// plus vite que le reseau ne repond — sinon la derniere reponse
// *arrivee* gagnerait au lieu de la derniere *demandee*).
let fetchToken = 0
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
// chargement (etat initial = 0 items mais on ne sait pas encore si la
// ressource est vide ou en cours de chargement). Un appel reseau au
@@ -166,23 +184,26 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
const isSinglePage = computed(() => totalPages.value <= 1)
/**
* Construit l'objet query envoye a l'API : pagination + tri + filtres +
* extras propres a la ressource. Les filtres `null`/`undefined`/'' sont
* elimines pour ne pas polluer l'URL.
* Construit l'objet query envoye a l'API : extras + filtres, puis
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
* extra portant le meme nom ne puisse pas ecraser silencieusement la
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
* pas polluer l'URL.
*/
function buildQuery(): Record<string, unknown> {
const query: Record<string, unknown> = {
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
}
if (sort.value) {
// Format API Platform : ?order[field]=asc
query[`order[${sort.value.field}]`] = sort.value.direction
}
const query: Record<string, unknown> = {}
if (options.extraQuery) {
Object.assign(query, options.extraQuery)
}
Object.assign(query, filters.value)
// Cles reservees en dernier : priorite a la pagination/au tri.
query.page = currentPage.value
query.itemsPerPage = itemsPerPage.value
if (sort.value) {
// Format API Platform : ?order[field]=asc
query[`order[${sort.value.field}]`] = sort.value.direction
}
return compactQuery(query)
}
@@ -194,13 +215,18 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
* pour eviter une boucle si l'API renvoie des resultats incoherents.
*/
async function fetch(): Promise<void> {
const token = ++fetchToken
loading.value = true
error.value = null
try {
const data = await api.get<HydraCollection<T>>(
options.url,
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Une requete plus recente a ete lancee entre-temps : on jette
// cette reponse pour ne pas ecraser des donnees plus fraiches.
if (token !== fetchToken) return
items.value = data.member ?? []
totalItems.value = data.totalItems ?? 0
@@ -220,20 +246,30 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Meme garde apres le refetch hors-borne.
if (token !== fetchToken) return
items.value = data2.member ?? []
totalItems.value = data2.totalItems ?? 0
}
hasFetched.value = true
} catch {
} catch (e) {
// Reponse periemee : ne pas toucher au state, une requete plus
// recente est en cours et fera foi.
if (token !== fetchToken) return
// Swallow volontaire : on remet la liste a vide pour ne pas
// afficher de donnees stale. Le composant parent decide de
// l'UX (toast / message d'erreur) — pas d'a-priori ici.
// afficher de donnees stale, et on expose l'erreur pour que la
// page distingue « vide » d'« echec ». Le composant parent
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
error.value = e
items.value = []
totalItems.value = 0
hasFetched.value = true
} finally {
loading.value = false
// Seule la requete la plus recente eteint le spinner : une
// reponse periemee ne doit pas le couper alors qu'un fetch plus
// recent est encore en vol.
if (token === fetchToken) loading.value = false
}
}
@@ -310,6 +346,7 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
itemsPerPageOptions,
totalPages,
loading,
error,
isEmpty,
isSinglePage,
filters,