diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 36d280c..b44d0c3 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -404,9 +404,13 @@ function useNgData(): FestipodDataContextValue { // → skip), so this is safe even if shapes finish hydrating after the timer. // Gated on NODE_ENV so production users see their own (possibly empty) wallet. // - // `eventsRead` is the guard that the ORM sets used to give for free: an empty - // Refine list means nothing until its query has actually succeeded, and seeding - // on an in-flight read would write the fixtures a second time. + // TWO readiness guards, and both are load-bearing. `eventsRead` says the Refine + // list has actually resolved — an empty list means nothing while the query is in + // flight, and seeding on it would write the fixtures a second time. `seedWritable` + // says the ORM subscriptions the seed WRITES INTO exist: since @ng-org/orm + // alpha.21 the set is `undefined` until the subscription resolves, so an add + // issued before then has nowhere to land and throws. + const seedWritable = usersShape.ready && participationsShape.ready; const hasTriedAutoSeed = useRef(false); useEffect(() => { if (process.env.NODE_ENV === 'production') return; @@ -414,8 +418,8 @@ function useNgData(): FestipodDataContextValue { if (hasTriedAutoSeed.current) return; if (!privateNuri) return; const t = setTimeout(() => { - if (!eventsRead) { - console.log('[FestipodData] Dev auto-seed: events not read yet — skip'); + if (!eventsRead || !seedWritable) { + console.log('[FestipodData] Dev auto-seed: subscriptions not ready yet — skip'); return; } hasTriedAutoSeed.current = true; @@ -432,7 +436,7 @@ function useNgData(): FestipodDataContextValue { } }, 3000); return () => clearTimeout(t); - }, [privateNuri, eventsRead, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]); + }, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]); // --- Derived --- // Resolve current user from the chosen account username (the perceived login); @@ -549,13 +553,27 @@ function useNgData(): FestipodDataContextValue { const loadTestData = useCallback(async (): Promise => { console.log('[FestipodData] loadTestData (NG)'); + // The seed WRITES into the ORM subscriptions, which since @ng-org/orm + // alpha.21 do not exist until they have loaded. Await the ORM's own + // readiness signal first — a `bootstrapWallet` started too early throws on + // its very first `add`, and callers see a wallet that seeded nothing. + // + // The RESOLVED VALUE is the set. Re-reading `usersShape.ngSet` after the + // await would hand over the placeholder captured by this closure, since the + // subscription resolving does not retroactively change the render that built + // this callback. `?? ngSet` covers the no-scope case, where there is no + // promise and the placeholder is the honest answer. + const [loadedUsers, loadedParticipations] = await Promise.all([ + usersShape.loaded, + participationsShape.loaded, + ]); return bootstrapWallet({ eventCount: eventRows.length, seedEvent: createEventDoc, - ngUsers: usersShape.ngSet as any, - ngParticipations: participationsShape.ngSet as any, + ngUsers: (loadedUsers ?? usersShape.ngSet) as any, + ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any, }); - }, [eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]); + }, [eventRows.length, createEventDoc, usersShape, participationsShape]); return { currentUserId, currentUser, diff --git a/src/shared/hooks/useShapeSet.ts b/src/shared/hooks/useShapeSet.ts index a08f622..b9c3f7f 100644 --- a/src/shared/hooks/useShapeSet.ts +++ b/src/shared/hooks/useShapeSet.ts @@ -1,5 +1,6 @@ /** - * useShapeSet — `useShape` narrowed back down to "just the set". + * useShapeSet — `useShape` narrowed back down to "just the set", plus the one + * fact the new contract forces callers to know: whether it has loaded. * * WHY THIS EXISTS. `@ng-org/orm` changed `useShape`'s contract between alpha.18 * and alpha.21: it used to RETURN the `DeepSignalSet`, and now returns a result @@ -13,11 +14,17 @@ * the name `OrmSubscription` and only alpha.21 exports `RdfOrmSubscription`. So * using the providers requires alpha.21, which requires absorbing this break. * - * WHILE LOADING, `data` is `undefined`. Callers get an empty set instead — and a - * set that REFUSES writes, which is what alpha.18 handed back for an undefined - * scope: *"an empty set is returned which errors if you try to make modifications - * on it"*. Accepting the write instead would drop it on the floor, silently, in - * exactly the window where a caller is most likely to try. + * WHAT CHANGES FOR WRITERS. Under alpha.18 a defined scope handed back a WRITABLE + * set straight away, so a caller could `add()` the moment it had the reference. + * Under alpha.21 `data` is `undefined` until the subscription resolves — there is + * genuinely no set to write to yet. So a writer must WAIT, and `ready` is what + * lets it: `useShapeWithDefaults` surfaces it, and the seeding path gates on it. + * (Only a scope of `undefined` never becomes ready — that is the "no scope" + * case, which alpha.18 also refused writes on.) + * + * The placeholder returned meanwhile refuses writes rather than accepting them + * into a detached `Set`, because a silently dropped seed is far harder to notice + * than a thrown one — it looks exactly like a wallet that persisted nothing. */ import { useMemo } from 'react'; @@ -32,26 +39,50 @@ import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; */ export type ShapeScope = string | { graphs: string[] } | undefined; +export interface ShapeSubscription { + /** The live set, or a write-refusing placeholder until `ready`. */ + set: DeepSignalSet; + /** `true` once the subscription has produced its set — a write may land. */ + ready: boolean; + /** + * Resolves when the subscription has loaded — the ORM's own readiness signal, + * so a writer AWAITS it instead of re-reading in a loop (`rule_no-broker-polling`). + * `undefined` when the scope is `undefined`: there is nothing to wait for. + */ + promise: Promise | undefined; +} + /** An empty set that names the problem instead of swallowing a write. */ function notLoadedSet(): DeepSignalSet { const refuse = (): never => { throw new Error( - 'useShapeSet: the shape subscription has not loaded yet — read it, do not write to it', + 'useShapeSet: the shape subscription has not loaded yet — await `ready` before writing', ); }; return Object.assign(new Set(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet; } -export function useShapeSet( +/** The set plus its readiness — for callers that WRITE. */ +export function useShapeSubscription( shapeType: ShapeType, scope: ShapeScope, -): DeepSignalSet { +): ShapeSubscription { // Memoised so the placeholder keeps ONE identity for this hook instance: it // lands in `useCallback` dependency arrays, and a fresh set on every render // would rebuild every mutation callback for as long as the load takes. const placeholder = useMemo(() => notLoadedSet(), []); const result = useShape(shapeType as never, scope as never) as unknown as { data?: DeepSignalSet; + promise?: Promise; }; - return result?.data ?? placeholder; + const data = result?.data; + return { set: data ?? placeholder, ready: data !== undefined, promise: result?.promise }; +} + +/** The set alone — for callers that only READ. */ +export function useShapeSet( + shapeType: ShapeType, + scope: ShapeScope, +): DeepSignalSet { + return useShapeSubscription(shapeType, scope).set; } diff --git a/src/shared/hooks/useShapeWithDefaults.ts b/src/shared/hooks/useShapeWithDefaults.ts index 39b357b..127472f 100644 --- a/src/shared/hooks/useShapeWithDefaults.ts +++ b/src/shared/hooks/useShapeWithDefaults.ts @@ -10,13 +10,21 @@ import type { ShapeType, BaseType } from '@ng-org/shex-orm'; import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; -import { useShapeSet, type ShapeScope } from './useShapeSet'; +import { useShapeSubscription, type ShapeScope } from './useShapeSet'; export interface ShapeWithDefaults { /** Mapped items from NG store */ items: AppT[]; /** Raw NG signal set for mutations */ ngSet: DeepSignalSet; + /** + * `true` once the subscription holds a real set. A WRITER must check this: + * since @ng-org/orm alpha.21 the set does not exist until the subscription + * resolves, so an `ngSet.add(…)` issued before then has nowhere to land. + */ + ready: boolean; + /** Resolves once the subscription has loaded — await it before writing. */ + loaded: Promise | undefined; } // `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM); @@ -32,10 +40,10 @@ export function useShapeWithDefaults( ): ShapeWithDefaults { // Mono-store: a single store NURI opens the repo in the verifier (enables // writes). Multi-document: a { graphs } scope subscribes to several docs. - // `useShapeSet` is what unwraps the ORM's result object — see that module. - const ngSet = useShapeSet(shapeType, storeNuri); + // `useShapeSubscription` is what unwraps the ORM's result object — see that module. + const { set: ngSet, ready, promise: loaded } = useShapeSubscription(shapeType, storeNuri); const usingDefaults = !shapesReady; const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT)); - return { items, ngSet }; + return { items, ngSet, ready, loaded }; }