feat(ui): spinner global près du titre Festipod + log du délai des requêtes

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] <shape>/<scope> premier résultat en <N>ms (n=<len>)
→ 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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 13:51:36 +02:00
parent c7e924abe7
commit 39b67feea0
8 changed files with 231 additions and 4 deletions
+11
View File
@@ -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;
+7 -2
View File
@@ -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<Set<string>>(new Set());
const myEvents = getUserEvents(currentUserId);
@@ -94,7 +96,10 @@ export function HomeScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: '12px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title style={{ margin: 0 }}>Festipod</Title>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Title style={{ margin: 0 }}>Festipod</Title>
{pendingQueries > 0 && <Spinner />}
</div>
<button
onClick={() => navigate('/events/new')}
aria-label="Relayer un événement"
+19
View File
@@ -0,0 +1,19 @@
import { Loader2 } from 'lucide-react';
/**
* Spinner — a small, discreet rotating indicator. Rendered next to the "Festipod"
* title while at least one data query is still waiting for its first result (see
* the global `usePendingQueries` store). The rotation keyframe (`app-spin`) and the
* `.app-spinner` class live in `src/index.css`, the style source of truth.
*/
export function Spinner({ size = 16, color = 'var(--app-accent)' }: { size?: number; color?: string }) {
return (
<Loader2
className="app-spinner"
size={size}
color={color}
aria-label="Chargement des données"
role="status"
/>
);
}
+1
View File
@@ -16,3 +16,4 @@ export { EventCover, getEventPhotoUrl } from './EventCover';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { Divider } from './Divider';
export { Spinner } from './Spinner';
+65
View File
@@ -0,0 +1,65 @@
import { expect, test, beforeEach } from 'bun:test';
import { beginQuery, resolveQuery, getPendingCount } from './pendingQueries';
// The store is a module singleton; drain it between tests so each starts clean.
beforeEach(() => {
// Resolve any leftover ids from a prior test. We don't know them, so use a fixed
// set covering the ids this file introduces.
['a', 'b', 'c'].forEach(resolveQuery);
});
test('count starts at 0', () => {
expect(getPendingCount()).toBe(0);
});
test('beginQuery increments, resolveQuery decrements', () => {
beginQuery('a');
expect(getPendingCount()).toBe(1);
beginQuery('b');
expect(getPendingCount()).toBe(2);
resolveQuery('a');
expect(getPendingCount()).toBe(1);
resolveQuery('b');
expect(getPendingCount()).toBe(0);
});
test('beginQuery is idempotent per id (no double-count under StrictMode)', () => {
beginQuery('a');
beginQuery('a');
beginQuery('a');
expect(getPendingCount()).toBe(1);
resolveQuery('a');
expect(getPendingCount()).toBe(0);
});
test('resolveQuery of an absent id is a no-op', () => {
expect(getPendingCount()).toBe(0);
resolveQuery('a');
expect(getPendingCount()).toBe(0);
beginQuery('a');
resolveQuery('a');
resolveQuery('a'); // second resolve: harmless
expect(getPendingCount()).toBe(0);
});
test('spinner-off only when ALL pending resolve', () => {
beginQuery('a');
beginQuery('b');
beginQuery('c');
expect(getPendingCount()).toBe(3);
resolveQuery('a');
resolveQuery('b');
expect(getPendingCount()).toBe(1); // still one waiting → spinner stays on
resolveQuery('c');
expect(getPendingCount()).toBe(0); // all resolved → spinner off
});
test('re-begin after resolve works (identity/scope switch new cycle)', () => {
beginQuery('a');
resolveQuery('a');
expect(getPendingCount()).toBe(0);
beginQuery('a'); // new cycle, same id reused
expect(getPendingCount()).toBe(1);
resolveQuery('a');
expect(getPendingCount()).toBe(0);
});
+62
View File
@@ -0,0 +1,62 @@
/**
* pendingQueries — a tiny module-level store that tracks data queries which have
* NOT yet received their first result (the transition `isPending → success|error`).
*
* It exists so a GLOBAL spinner (near the "Festipod" title) can turn while AT
* LEAST ONE query is still waiting for its first result, and stop ONLY once EVERY
* in-flight query has resolved. It is deliberately independent of React provider
* order: it is a plain module singleton, driven by `useShapeQuery` (see B) and
* observed by `usePendingQueries` via `useSyncExternalStore`.
*
* Contract:
* - `beginQuery(id)` — a query enters the "waiting for first result" state.
* - `resolveQuery(id)` — that query got its first result (or errored, or its
* component unmounted while still pending). Removing an absent id is a no-op.
* - The store is a Set of ids, so `beginQuery(id)` twice does NOT double-count and
* `resolveQuery(id)` twice is harmless — this is what makes it safe under React
* StrictMode's symmetric double-invocation of effects, and lets a query that
* re-enters `isPending` (e.g. an identity/scope switch) begin again cleanly.
*/
import { useSyncExternalStore } from 'react';
const pending = new Set<string>();
const listeners = new Set<() => void>();
function emit(): void {
for (const l of listeners) l();
}
/** Mark a query as waiting for its first result. Idempotent per id. */
export function beginQuery(id: string): void {
if (pending.has(id)) return;
pending.add(id);
emit();
}
/** Mark a query as having received its first result. No-op if absent. */
export function resolveQuery(id: string): void {
if (!pending.delete(id)) return;
emit();
}
/** Number of queries currently waiting for their first result. */
export function getPendingCount(): number {
return pending.size;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Subscribe a React component to the pending-query count. Re-renders whenever the
* count changes. `getPendingCount` returns a primitive (number), so the snapshot
* is stable between real changes — no render loop.
*/
export function usePendingQueries(): number {
return useSyncExternalStore(subscribe, getPendingCount, getPendingCount);
}
+62 -2
View File
@@ -14,13 +14,27 @@
* would tear down and re-establish the underlying doc subscriptions on each
* render. We key the memo on the scope plus the shape's identity (its `@type` /
* schema-shape) so a stable shapeType yields a stable observable.
*
* TIMING + GLOBAL PENDING: on top of the read, this hook instruments EVERY query
* cycle. When a fresh observable starts waiting for its first result (`isPending`)
* it registers with the module-level `pendingQueries` store (feeding the global
* spinner) and stamps `performance.now()`. On the first result (`isPending →
* success|error`) it resolves the registration and logs the elapsed delay. A new
* cycle (identity/scope switch recreates the observable) re-registers and
* re-measures — a query is never "resolved forever". See pendingQueries.ts.
*/
import { useMemo, useSyncExternalStore } from 'react';
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/client';
import { beginQuery, resolveQuery } from './pendingQueries';
type Scope = 'public' | 'protected' | 'private';
// Monotonic sequence so several instances of the SAME (shapeType, scope) — e.g.
// two components reading the same shape — get distinct pending ids and never
// collide in the pending Set.
let instanceSeq = 0;
/**
* Bind a reactive, `useQuery`-shaped read over one SHEX `shapeType` in one logical
* `scope`. Returns the live `ShapeQuery<T>` (`{ data, isPending, isSuccess,
@@ -46,5 +60,51 @@ export function useShapeQuery<T = UnionSubject>(
[shapeKey, scope],
);
return useSyncExternalStore(obs.subscribe, obs.getSnapshot);
const query = useSyncExternalStore(obs.subscribe, obs.getSnapshot);
// --- TIMING + GLOBAL PENDING (per query CYCLE) ---------------------------
// A "cycle" = one observable's lifetime. `cycleId` is stable per observable and
// unique per instance (seq counter), so an identity/scope switch (new observable)
// yields a NEW cycle id → a fresh begin/measure. Recomputed only when the memo
// key changes, which is exactly when a new observable is created.
const cycleId = useMemo(
() => `${shapeKey}/${scope}#${++instanceSeq}`,
// eslint-disable-next-line react-hooks/exhaustive-deps
[shapeKey, scope],
);
// Track whether THIS cycle has already resolved, so we log/resolve exactly once
// and stay symmetric under StrictMode's double-invoked effects.
const startedAtRef = useRef<number | null>(null);
const resolvedRef = useRef(false);
// Begin the cycle: mark pending + stamp start time. Keyed on cycleId so a new
// observable re-begins. Cleanup resolves if we unmount (or the cycle changes)
// while still pending — otherwise the spinner would hang. Idempotent store ops
// make the StrictMode double-run harmless.
useEffect(() => {
startedAtRef.current = performance.now();
resolvedRef.current = false;
beginQuery(cycleId);
return () => {
resolveQuery(cycleId);
};
}, [cycleId]);
// On the first result of this cycle (isPending → success|error), resolve the
// registration once and log the delay. Guarded by resolvedRef so the double
// effect run under StrictMode logs a single line per real transition.
useEffect(() => {
if (resolvedRef.current) return;
if (query.isPending) return;
resolvedRef.current = true;
const startedAt = startedAtRef.current;
const elapsed = startedAt == null ? 0 : Math.round(performance.now() - startedAt);
resolveQuery(cycleId);
const n = Array.isArray(query.data) ? query.data.length : 0;
// eslint-disable-next-line no-console
console.log(`[FestipodData] ${shapeKey}/${scope} premier résultat en ${elapsed}ms (n=${n})`);
}, [query, cycleId, shapeKey, scope]);
return query;
}