From 753282c7b1fc939ac98d29617e3a37bd0046a295 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 21 Sep 2026 12:17:40 +0200 Subject: [PATCH] Absorb the useShape contract change the provider versions require @ng-org/orm stopped returning the DeepSignalSet from useShape and now returns a result object carrying it under data. The bump is forced: the providers import RdfOrmSubscription, which alpha.18 published as OrmSubscription. useShapeSet unwraps it in one place and hands back a set, so the call sites keep the contract they were written against. While the subscription is loading it yields an empty set that refuses writes rather than dropping them, matching what the previous version documented. --- src/shared/hooks/useShapeSet.ts | 57 ++++++++++++++++++++++++ src/shared/hooks/useShapeWithDefaults.ts | 15 +++---- src/shared/test-harness/harness-ng.tsx | 12 ++--- 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 src/shared/hooks/useShapeSet.ts 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,