Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
8 changed files with 231 additions and 4 deletions
Showing only changes of commit 39b67feea0 - Show all commits
@@ -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.
+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;
}