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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user