feat(perf): implement performance optimizations for session handling

- Introduced a new configuration file `config.yaml` for specifying project context and artifact rules.
- Added `.openspec.yaml` files for tracking changes related to performance improvements.
- Created design documents outlining the context, goals, decisions, and migration plans for optimizing session performance.
- Proposed changes include batching database queries, debouncing event refreshes, purging old events, and implementing loading states for better user experience.
- Added tasks and specifications to ensure proper implementation and validation of the new features.

These enhancements aim to improve the scalability and responsiveness of the application during collaborative sessions.
This commit is contained in:
2026-03-10 08:06:47 +01:00
parent 6baa9bfada
commit 2d266f89f9
19 changed files with 519 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-09

View File

@@ -0,0 +1,57 @@
## Context
L'application charge les collaborateurs de session via `resolveCollaborator` appelé en séquence dans une boucle (N+1). Le hook `useLive` déclenche `router.refresh()` sur chaque événement SSE reçu, sans groupement, causant des re-renders en cascade si plusieurs événements arrivent simultanément. La fonction `cleanupOldEvents` existe dans `session-share-events.ts` mais n'est jamais appelée, laissant les événements s'accumuler indéfiniment. L'absence de `loading.tsx` sur les routes principales empêche le streaming App Router de s'activer. Les modals (`ShareModal`) sont inclus dans le bundle initial alors qu'ils sont rarement utilisés.
## Goals / Non-Goals
**Goals:**
- Éliminer le N+1 sur `resolveCollaborator` avec un fetch batché
- Grouper les refreshes SSE consécutifs avec un debounce
- Purger les événements SSE au fil de l'eau (après chaque `createEvent`)
- Activer le streaming de navigation avec `loading.tsx` sur les routes à chargement lent
- Réduire le bundle JS initial en lazy-loadant les modals
**Non-Goals:**
- Refactorer l'architecture SSE (sujet Phase 2)
- Changer la stratégie de cache/revalidation (sujet Phase 2)
- Optimiser les requêtes Prisma profondes (sujet Phase 3)
- Modifier le comportement fonctionnel existant
## Decisions
### 1. Batch resolveCollaborator par collect + single query
**Décision** : Dans `session-queries.ts`, collecter tous les `userId` des collaborateurs d'une liste de sessions, puis faire un seul `prisma.user.findMany({ where: { id: { in: [...ids] } } })`, et mapper les résultats en mémoire.
**Alternatives** : Garder le N+1 mais ajouter un cache mémoire par requête → rejeté car ne résout pas le problème structurellement.
### 2. Debounce via useRef + setTimeout natif
**Décision** : Dans `useLive.ts`, utiliser `useRef` pour stocker un timer et `setTimeout` / `clearTimeout` pour debounce à 300ms. Pas de dépendance externe.
**Alternatives** : Bibliothèque `lodash.debounce` → rejeté pour éviter une dépendance pour 5 lignes.
### 3. cleanupOldEvents inline dans createEvent
**Décision** : Appeler `cleanupOldEvents` à la fin de chaque `createEvent` (fire-and-forget, pas d'await bloquant). La purge garde les 50 derniers événements par session (seuil actuel).
**Alternatives** : Cron externe → trop complexe pour un quick win ; interval côté API SSE → couplage non souhaité.
### 4. loading.tsx avec skeleton minimaliste
**Décision** : Créer un `loading.tsx` par route principale (`/sessions`, `/weather`, `/users`) avec un skeleton générique (barres grises animées). Le composant est statique et ultra-léger.
### 5. next/dynamic avec ssr: false sur les modals
**Décision** : Wrapper `ShareModal` (et `CollaborationToolbar` si pertinent) avec `next/dynamic({ ssr: false })`. Le composant parent gère le loading state.
## Risks / Trade-offs
- **Debounce 300ms** → légère latence perçue sur les mises à jour collaboratives. Mitigation : valeur configurable via constante.
- **cleanupOldEvents fire-and-forget** → si la purge échoue, les erreurs sont silencieuses. Mitigation : logger l'erreur sans bloquer.
- **Batch resolveCollaborator** → si la liste de sessions est très grande (>500), la requête `IN` peut être lente. Mitigation : acceptable pour les volumes actuels ; paginer si nécessaire (Phase 3).
- **next/dynamic ssr: false** → les modals ne sont pas rendus côté serveur. Acceptable car ils sont interactifs uniquement.
## Migration Plan
Chaque optimisation est indépendante et déployable séparément. Pas de migration de données. Rollback : revert du commit concerné. L'ordre recommandé : (1) batch resolveCollaborator, (2) cleanupOldEvents, (3) debounce useLive, (4) loading.tsx, (5) next/dynamic.

View File

@@ -0,0 +1,29 @@
## Why
Les routes principales souffrent de plusieurs problèmes de performance facilement corrigeables : N+1 sur `resolveCollaborator`, re-renders en cascade dans `useLive`, accumulation illimitée d'événements SSE, et absence de feedback visuel pendant les navigations. Ces quick wins peuvent être adressés indépendamment, sans refactoring architectural.
## What Changes
- **Batch resolveCollaborator** : remplacer les appels séquentiels par un batch unique dans `session-queries.ts` (élimination N+1)
- **Debounce router.refresh()** : ajouter un debounce ~300ms dans `useLive.ts` pour grouper les événements SSE simultanés
- **Appel de cleanupOldEvents** : intégrer l'appel à `cleanupOldEvents` dans `createEvent` pour purger les vieux événements au fil de l'eau
- **Ajout de `loading.tsx`** : ajouter des fichiers `loading.tsx` sur les routes `/sessions`, `/weather`, `/users` pour activer le streaming App Router
- **Lazy-load des modals** : utiliser `next/dynamic` sur `ShareModal` et autres modals lourds pour réduire le bundle JS initial
## Capabilities
### New Capabilities
- `perf-loading-states`: Feedback visuel de chargement sur les routes principales via `loading.tsx`
### Modified Capabilities
- Aucune modification de spec existante — les changements sont purement implémentation/performance
## Impact
- `src/services/session-queries.ts` — refactoring batch resolveCollaborator
- `src/hooks/useLive.ts` — ajout debounce sur router.refresh
- `src/services/session-share-events.ts` — appel cleanupOldEvents dans createEvent
- `src/app/sessions/loading.tsx`, `src/app/weather/loading.tsx`, `src/app/users/loading.tsx` — nouveaux fichiers
- Composants qui importent `ShareModal` — passage à import dynamique

View File

@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: Loading skeleton on main routes
The application SHALL display a skeleton loading state during navigation to `/sessions`, `/weather`, and `/users` routes, activated by Next.js App Router streaming via `loading.tsx` files.
#### Scenario: Navigation to sessions page shows skeleton
- **WHEN** a user navigates to `/sessions`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
#### Scenario: Navigation to weather page shows skeleton
- **WHEN** a user navigates to `/weather`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
#### Scenario: Navigation to users page shows skeleton
- **WHEN** a user navigates to `/users`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
### Requirement: Modal lazy loading
Heavy modal components (ShareModal) SHALL be loaded lazily via `next/dynamic` to reduce the initial JS bundle size.
#### Scenario: ShareModal not in initial bundle
- **WHEN** a page loads that contains a ShareModal trigger
- **THEN** the ShareModal component code SHALL NOT be included in the initial JS bundle
- **THEN** the ShareModal code SHALL be fetched only when first needed

View File

@@ -0,0 +1,33 @@
## 1. Batch resolveCollaborator (N+1 fix)
- [x] 1.1 Lire `src/services/session-queries.ts` et identifier toutes les occurrences de `resolveCollaborator` appelées en boucle
- [x] 1.2 Créer une fonction `batchResolveCollaborators(userIds: string[])` qui fait un seul `prisma.user.findMany({ where: { id: { in: userIds } } })`
- [x] 1.3 Remplacer les boucles N+1 par collect des IDs → batch query → mapping en mémoire
- [x] 1.4 Vérifier que les pages sessions/weather/etc. chargent correctement
## 2. Debounce router.refresh() dans useLive
- [x] 2.1 Lire `src/hooks/useLive.ts` et localiser l'appel à `router.refresh()`
- [x] 2.2 Ajouter un `useRef<ReturnType<typeof setTimeout>>` pour le timer de debounce
- [x] 2.3 Wrapper l'appel `router.refresh()` avec `clearTimeout` + `setTimeout` à 300ms
- [x] 2.4 Ajouter un `clearTimeout` dans le cleanup de l'effet pour éviter les leaks mémoire
## 3. Purge automatique des événements SSE
- [x] 3.1 Lire `src/services/session-share-events.ts` et localiser `createEvent` et `cleanupOldEvents`
- [x] 3.2 Ajouter un appel fire-and-forget à `cleanupOldEvents` à la fin de `createEvent` (après l'insert)
- [x] 3.3 Wrapper l'appel dans un try/catch pour logger l'erreur sans bloquer
## 4. Ajout des loading.tsx sur les routes principales
- [x] 4.1 Créer `src/app/sessions/loading.tsx` avec un skeleton de liste de sessions
- [x] 4.2 Créer `src/app/weather/loading.tsx` avec un skeleton de tableau météo
- [x] 4.3 Créer `src/app/users/loading.tsx` avec un skeleton de liste utilisateurs
- [ ] 4.4 Vérifier que le skeleton s'affiche bien à la navigation (ralentir le réseau dans DevTools)
## 5. Lazy-load des modals avec next/dynamic
- [x] 5.1 Identifier tous les composants qui importent `ShareModal` directement
- [x] 5.2 Remplacer chaque import statique par `next/dynamic(() => import(...), { ssr: false })`
- [ ] 5.3 Vérifier que les modals s'ouvrent correctement après lazy-load
- [ ] 5.4 Vérifier dans les DevTools Network que le chunk modal n'est pas dans le bundle initial