diff --git a/src/shared/hooks/useShapeSet.ts b/src/shared/hooks/useShapeSet.ts new file mode 100644 index 0000000..a08f622 --- /dev/null +++ b/src/shared/hooks/useShapeSet.ts @@ -0,0 +1,57 @@ +/** + * useShapeSet — `useShape` narrowed back down to "just the set". + * + * 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 + * object that carries it under `data`, alongside `isLoading`, `promise`, + * `subscription` and the paging controls. Every Festipod call site wants the set, + * so the whole adaptation lives HERE, in one place, rather than being spelled out + * at each `useShape(...)`. + * + * AND THE BUMP IS NOT OPTIONAL. `@ng-org/refine-providers` imports + * `RdfOrmSubscription` from `@ng-org/orm`; alpha.18 published that class under + * 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. + */ + +import { useMemo } from 'react'; +import { useShape } from '@ng-org/orm/react'; +import type { ShapeType, BaseType } from '@ng-org/shex-orm'; +import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; + +/** + * `scope` is either a single store/document NURI (mono-store mode) or a + * `{ graphs }` set of document NURIs (multi-document mode — storeRegistry). + * `useShape` accepts both natively. + */ +export type ShapeScope = string | { graphs: string[] } | 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', + ); + }; + return Object.assign(new Set(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet; +} + +export function useShapeSet( + shapeType: ShapeType, + scope: ShapeScope, +): DeepSignalSet { + // 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; + }; + return result?.data ?? placeholder; +} diff --git a/src/shared/hooks/useShapeWithDefaults.ts b/src/shared/hooks/useShapeWithDefaults.ts index cf1667e..39b357b 100644 --- a/src/shared/hooks/useShapeWithDefaults.ts +++ b/src/shared/hooks/useShapeWithDefaults.ts @@ -8,9 +8,10 @@ * Must only be called when NG is connected (inside NgDataProvider). */ -import { useShape } from '@ng-org/orm/react'; import type { ShapeType, BaseType } from '@ng-org/shex-orm'; import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; +import { useShapeSet, type ShapeScope } from './useShapeSet'; + export interface ShapeWithDefaults { /** Mapped items from NG store */ items: AppT[]; @@ -18,12 +19,9 @@ export interface ShapeWithDefaults { ngSet: DeepSignalSet; } -/** - * `scope` is either a single store/document NURI (mono-store mode) or a - * `{ graphs }` set of document NURIs (multi-document mode — storeRegistry). - * `useShape` accepts both natively. - */ -export type ShapeScope = string | { graphs: string[] } | undefined; +// `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM); +// it is re-exported here so the existing importers stay unchanged. +export type { ShapeScope }; export function useShapeWithDefaults( shapeType: ShapeType, @@ -34,7 +32,8 @@ 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. - const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet; + // `useShapeSet` is what unwraps the ORM's result object — see that module. + const ngSet = useShapeSet(shapeType, storeNuri); const usingDefaults = !shapesReady; const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT)); diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index cef00f2..eda48b3 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -12,7 +12,7 @@ import { createRoot } from 'react-dom/client'; import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext'; import { RefineProvider } from '../refine/RefineProvider'; import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext'; -import { useShape } from '@ng-org/orm/react'; +import { useShapeSet } from '../hooks/useShapeSet'; import { ng } from '@ng-org/web'; import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; import { @@ -67,9 +67,9 @@ function ConnectedHarness() { // Use private store NURI as scope (opens the repo for reads AND writes) const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`; - const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet; - const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet; - const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet; + const events = useShapeSet(FpEventShapeType, privateNuri); + const users = useShapeSet(FpUserProfileShapeType, privateNuri); + const participations = useShapeSet(FpParticipationShapeType, privateNuri); const [bridgeReady, setBridgeReady] = useState(false); // Stopgap multi-store validation: a doc created on demand via doc_create, @@ -239,7 +239,7 @@ function ConnectedHarness() { // ============================================================================ function FanoutProbe({ graphs }: { graphs: string[] }) { - const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet; + const set = useShapeSet(FpEventShapeType, { graphs }); useEffect(() => { (window as any).__fanout = { ready: true, @@ -266,7 +266,7 @@ function FanoutProbe({ graphs }: { graphs: string[] }) { // ============================================================================ function SmokeProbe({ docNuri }: { docNuri: string }) { - const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet; + const set = useShapeSet(FpParticipationShapeType, { graphs: [docNuri] }); useEffect(() => { (window as any).__smoke = { ready: true,