From 39b67feea095ab67c82c417a853de42ad260b436 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 13 Jul 2026 13:51:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20spinner=20global=20pr=C3=A8s=20du?= =?UTF-8?q?=20titre=20Festipod=20+=20log=20du=20d=C3=A9lai=20des=20requ?= =?UTF-8?q?=C3=AAtes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque useShapeQuery s'enregistre dans un store module-level pendingQueries au début de son cycle et se résout à son premier résultat (isPending→isSuccess|isError, équivalent readPromise). HomeScreen affiche un Spinner à côté du titre "Festipod" tant qu'au moins une requête est en attente ; il ne s'arrête que quand TOUTES ont reçu leur premier résultat. Toute future useShapeQuery y contribue automatiquement. À la 1re résolution, chaque cycle logge son délai : [FestipodData] / premier résultat en ms (n=) → le délai d'obtention des événements (Event/public) est visible nommément. Store idempotent (Set d'ids, sûr sous StrictMode) ; cycleId mémoïsé sur [shapeKey, scope] → re-begin sur switch d'identité, cleanup résout au démontage (spinner jamais bloqué). Spinner = Loader2 lucide + @keyframes app-spin dans index.css. Tests: pendingQueries.test.ts (6, dont "off seulement quand toutes résolues"). Doctrine: data-layer/knowledge_context-internals. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data-layer/knowledge_context-internals.md | 4 ++ src/index.css | 11 ++++ src/modules/home/screens/HomeScreen.tsx | 9 ++- src/shared/components/sketchy/Spinner.tsx | 19 ++++++ src/shared/components/sketchy/index.ts | 1 + src/shared/data/pendingQueries.test.ts | 65 +++++++++++++++++++ src/shared/data/pendingQueries.ts | 62 ++++++++++++++++++ src/shared/data/useShapeQuery.ts | 64 +++++++++++++++++- 8 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 src/shared/components/sketchy/Spinner.tsx create mode 100644 src/shared/data/pendingQueries.test.ts create mode 100644 src/shared/data/pendingQueries.ts diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index c050314..86c05ef 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -69,6 +69,10 @@ Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les doc **Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides). +## Instrumentation `useShapeQuery` — spinner global + timing + +`useShapeQuery` (binding `useSyncExternalStore` sur `watchShape`) instrumente **chaque cycle de requête** : au début d'un cycle il s'enregistre dans un store module-level `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, Set d'ids — idempotent, sûr sous StrictMode), et à la 1re transition `isPending → isSuccess|isError` (le « premier résultat », équivalent readPromise) il se résout ET logge le délai : `[FestipodData] / premier résultat en ms (n=)` (le délai des événements Event/public est donc visible nommément). Le `cycleId` est mémoïsé sur `[shapeKey, scope]` → un switch d'identité/scope recrée l'observable ET un nouveau cycle (re-`beginQuery`), et le cleanup résout au démontage (jamais bloqué). Le hook `usePendingQueries()` expose le nombre de requêtes en attente ; `HomeScreen` affiche un `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` dans `index.css`) à côté du titre « Festipod » tant que le compte > 0 → il ne s'arrête que quand **toutes** les requêtes en cours ont reçu leur premier résultat. Toute future `useShapeQuery` y contribue automatiquement. La mesure vit côté app (délai perçu React), **pas** dans le polyfill. + ## Mutations no-op en mode local En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut. diff --git a/src/index.css b/src/index.css index f8de2a7..8eff2a7 100644 --- a/src/index.css +++ b/src/index.css @@ -333,6 +333,17 @@ body { min-height: 0; } +/* Global data-query spinner (near the "Festipod" title) */ +@keyframes app-spin { + to { transform: rotate(360deg); } +} + +.app-spinner { + animation: app-spin 0.8s linear infinite; + flex-shrink: 0; + vertical-align: middle; +} + /* Online indicator on avatar */ .app-avatar .online-dot { position: absolute; diff --git a/src/modules/home/screens/HomeScreen.tsx b/src/modules/home/screens/HomeScreen.tsx index d54ef03..fdc3222 100644 --- a/src/modules/home/screens/HomeScreen.tsx +++ b/src/modules/home/screens/HomeScreen.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, type MeetingPointData } from '../../../shared/components/sketchy'; +import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { usePendingQueries } from '../../../shared/data/pendingQueries'; import { useNavigate } from '../../../app/router'; const PEOPLE = [ @@ -65,6 +66,7 @@ function EventCardBody({ export function HomeScreen() { const navigate = useNavigate(); const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData(); + const pendingQueries = usePendingQueries(); const [joinedIds, setJoinedIds] = useState>(new Set()); const myEvents = getUserEvents(currentUserId); @@ -94,7 +96,10 @@ export function HomeScreen() {
- Festipod +
+ Festipod + {pendingQueries > 0 && } +