Ng eventually #1
@@ -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] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (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.
|
||||
|
||||
@@ -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