this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Share one FileManager per context in @opake/react hooks

Every mutation that went through withFileManager used to build its own
FileManager via opake.workspace() or opake.cabinet(). For workspaces
that meant a fresh PDS getRecord round-trip on the keyring before each
delete / upload / rename / move — useDirectory and useDirectoryMetadata
had their own long-lived FileManager from the provider cache, but the
mutation layer bypassed it entirely.

Rewrite withFileManager to acquire from the FileManagerCache and
release when done. Concurrent acquires share one instance, so a burst
of mutations hits the cache once and disposes once. Hooks that
previously called useOpake() for this now call useFileManagerCache().

While here, factor the duplicated bootstrap guard out of useWorkspaces
and useInbox into a shared bootstrap-once helper. The new version keys
the in-flight promise on (Opake, label) via a WeakMap, so switching
accounts mid-bootstrap doesn't short-circuit on a stale guard from the
previous identity — which the old module-level `let bootstrapPromise`
quietly did.

+90 -46
+51
packages/opake-react/src/hooks/bootstrap-once.ts
··· 1 + // Shared dedup guard for keeper bootstraps. 2 + // 3 + // Used by `useWorkspaces` and `useInbox` to dedupe the initial 4 + // `opake.list*()` call across concurrent mounts (e.g. StrictMode 5 + // double-mount, or multiple components consuming the same hook). 6 + // 7 + // Keyed by (Opake instance, label): 8 + // - Keying on the Opake instance isolates accounts — switching from 9 + // account A to account B constructs a fresh Opake, so B's bootstrap 10 + // isn't short-circuited by a stale A-era in-flight promise. 11 + // - Keying on a string label lets a single Opake run multiple 12 + // independent bootstraps (workspaces, inbox, ...) without them 13 + // deduplicating against each other. 14 + // 15 + // Uses a WeakMap so in-flight entries clear naturally when an Opake 16 + // instance is GC'd; the inner Map also deletes each label on settle. 17 + 18 + import type { Opake } from "@opake/sdk"; 19 + 20 + const inFlight = new WeakMap<Opake, Map<string, Promise<unknown>>>(); 21 + 22 + /** 23 + * Run a bootstrap once per (Opake, label) pair. If a prior call with 24 + * the same Opake + label is still pending, this is a no-op. 25 + * 26 + * @param opake - Opake instance identifying the account scope. 27 + * @param label - Human-readable name used for log context and dedup. 28 + * @param fetch - Returns the bootstrap promise; called at most once per pair. 29 + */ 30 + export function bootstrapOnce( 31 + opake: Opake, 32 + label: string, 33 + fetch: () => Promise<unknown>, 34 + ): void { 35 + // eslint-disable-next-line functional/no-let -- need mutable slot for lazy-init 36 + let labels = inFlight.get(opake); 37 + if (labels?.has(label)) return; 38 + if (!labels) { 39 + labels = new Map(); 40 + inFlight.set(opake, labels); 41 + } 42 + const slot = labels; 43 + const promise = fetch() 44 + .catch((err: unknown) => { 45 + console.warn(`[opake-react] ${label} bootstrap failed:`, err); 46 + }) 47 + .finally(() => { 48 + slot.delete(label); 49 + }); 50 + slot.set(label, promise); 51 + }
+3 -3
packages/opake-react/src/hooks/use-directory-metadata.ts
··· 1 1 import { useQuery, keepPreviousData } from "@tanstack/react-query"; 2 2 import type { DocumentMetadata } from "@opake/sdk"; 3 - import { useOpake } from "../provider"; 3 + import { useFileManagerCache } from "../provider"; 4 4 import { opakeKeys } from "../keys"; 5 5 import { withFileManager } from "./use-tree-mutation"; 6 6 ··· 22 22 * ``` 23 23 */ 24 24 export function useDirectoryMetadata(keyringUri: string | null, directoryUri: string | null) { 25 - const opake = useOpake(); 25 + const cache = useFileManagerCache(); 26 26 27 27 return useQuery<Readonly<Record<string, DocumentMetadata>>>({ 28 28 queryKey: opakeKeys.metadata(directoryUri ?? ""), 29 29 queryFn: async () => { 30 30 if (!directoryUri) throw new Error("no directory"); 31 - return withFileManager(opake, keyringUri, async (fm) => { 31 + return withFileManager(cache, keyringUri, async (fm) => { 32 32 const result = await fm.loadTreeWithMetadata(directoryUri); 33 33 return result.metadata; 34 34 });
+3 -3
packages/opake-react/src/hooks/use-download.ts
··· 1 1 import { useMutation } from "@tanstack/react-query"; 2 2 import type { DownloadResult } from "@opake/sdk"; 3 - import { useOpake } from "../provider"; 3 + import { useFileManagerCache } from "../provider"; 4 4 import { withFileManager } from "./use-tree-mutation"; 5 5 6 6 /** ··· 18 18 * ``` 19 19 */ 20 20 export function useDownload(keyringUri: string | null) { 21 - const opake = useOpake(); 21 + const cache = useFileManagerCache(); 22 22 23 23 return useMutation<DownloadResult, Error, string>({ 24 24 mutationFn: (documentUri) => 25 - withFileManager(opake, keyringUri, (fm) => fm.download(documentUri)), 25 + withFileManager(cache, keyringUri, (fm) => fm.download(documentUri)), 26 26 }); 27 27 }
+4 -11
packages/opake-react/src/hooks/use-inbox.ts
··· 12 12 import { useEffect, useState } from "react"; 13 13 import type { InboxGrant, InboxSnapshot } from "@opake/sdk"; 14 14 import { useOpake } from "../provider"; 15 - 16 - let bootstrapPromise: Promise<unknown> | null = null; 15 + import { bootstrapOnce } from "./bootstrap-once"; 17 16 18 17 interface UseInboxResult { 19 18 /** The current inbox entries, or an empty array before bootstrap. */ ··· 47 46 const [snapshot, setSnapshot] = useState<InboxSnapshot | null>(null); 48 47 49 48 useEffect(() => { 49 + // eslint-disable-next-line functional/no-let -- per-mount latch 50 50 let handledFirstFire = false; 51 51 52 52 const watcher = opake.watchInbox((snap) => { ··· 54 54 55 55 if (!handledFirstFire) { 56 56 handledFirstFire = true; 57 - if (!snap.loaded && !bootstrapPromise) { 58 - bootstrapPromise = opake 59 - .listInbox() 60 - .catch((err: unknown) => { 61 - console.warn("[opake-react] listInbox bootstrap failed:", err); 62 - }) 63 - .finally(() => { 64 - bootstrapPromise = null; 65 - }); 57 + if (!snap.loaded) { 58 + bootstrapOnce(opake, "listInbox", () => opake.listInbox()); 66 59 } 67 60 } 68 61 });
+18 -8
packages/opake-react/src/hooks/use-tree-mutation.ts
··· 2 2 // FileManager lifecycle, optimistic rollback, and query invalidation. 3 3 4 4 import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; 5 - import type { DirectoryTreeSnapshot, FileManager, Opake } from "@opake/sdk"; 6 - import { useOpake } from "../provider"; 5 + import type { DirectoryTreeSnapshot, FileManager } from "@opake/sdk"; 6 + import { useFileManagerCache } from "../provider"; 7 + import type { FileManagerCache } from "../file-manager-cache"; 7 8 import { opakeKeys } from "../keys"; 8 9 9 - /** Create a FileManager, run a callback, dispose. */ 10 + /** 11 + * Acquire a FileManager from the provider cache, run a callback, 12 + * release. Concurrent acquires share a single FileManager instance, 13 + * so a burst of mutations no longer re-fetches the workspace keyring 14 + * record from the PDS on every call. 15 + */ 10 16 export async function withFileManager<T>( 11 - opake: Opake, 17 + cache: FileManagerCache, 12 18 keyringUri: string | null, 13 19 fn: (fm: FileManager) => Promise<T>, 14 20 ): Promise<T> { 15 - const fm = keyringUri ? await opake.workspace(keyringUri) : await opake.cabinet(); 16 - return fn(fm).finally(() => fm.dispose()); 21 + const fm = await cache.acquire(keyringUri); 22 + try { 23 + return await fn(fm); 24 + } finally { 25 + cache.release(keyringUri); 26 + } 17 27 } 18 28 19 29 /** Resolve the React Query cache key for a tree (cabinet or workspace). */ ··· 43 53 export function useTreeMutation<TInput, TResult>( 44 54 options: TreeMutationOptions<TInput, TResult>, 45 55 ): UseMutationResult<TResult, Error, TInput> { 46 - const opake = useOpake(); 56 + const cache = useFileManagerCache(); 47 57 const queryClient = useQueryClient(); 48 58 const key = treeKeyFor(options.keyringUri); 49 59 50 60 return useMutation<TResult, Error, TInput, { previous?: DirectoryTreeSnapshot }>({ 51 61 mutationFn: (input) => 52 - withFileManager(opake, options.keyringUri, (fm) => options.mutationFn(fm, input)), 62 + withFileManager(cache, options.keyringUri, (fm) => options.mutationFn(fm, input)), 53 63 54 64 onMutate: options.optimisticUpdate 55 65 ? async (input) => {
+3 -3
packages/opake-react/src/hooks/use-tree.ts
··· 1 1 import { useQuery, keepPreviousData } from "@tanstack/react-query"; 2 2 import type { DirectoryTreeSnapshot } from "@opake/sdk"; 3 - import { useOpake } from "../provider"; 3 + import { useFileManagerCache } from "../provider"; 4 4 import { opakeKeys } from "../keys"; 5 5 import { withFileManager } from "./use-tree-mutation"; 6 6 ··· 28 28 * ``` 29 29 */ 30 30 export function useTree(keyringUri: string | null) { 31 - const opake = useOpake(); 31 + const cache = useFileManagerCache(); 32 32 33 33 return useQuery<DirectoryTreeSnapshot>({ 34 34 queryKey: keyringUri ? opakeKeys.workspaceTree(keyringUri) : opakeKeys.cabinetTree(), 35 - queryFn: () => withFileManager(opake, keyringUri, (fm) => fm.loadTree()), 35 + queryFn: () => withFileManager(cache, keyringUri, (fm) => fm.loadTree()), 36 36 placeholderData: keepPreviousData, 37 37 }); 38 38 }
+8 -18
packages/opake-react/src/hooks/use-workspaces.ts
··· 22 22 import { useEffect, useState } from "react"; 23 23 import type { WorkspaceEntry, WorkspaceSnapshot } from "@opake/sdk"; 24 24 import { useOpake } from "../provider"; 25 - 26 - // Module-level dedup guard. Once one mount kicks off `listWorkspaces`, 27 - // all other concurrent mounts (e.g. StrictMode double-mount, multiple 28 - // components using this hook) share the same in-flight promise instead 29 - // of each issuing a separate round-trip. 30 - let bootstrapPromise: Promise<unknown> | null = null; 25 + import { bootstrapOnce } from "./bootstrap-once"; 31 26 32 27 interface UseWorkspacesResult { 33 28 /** The current workspace list, or an empty array before bootstrap. */ ··· 68 63 const [snapshot, setSnapshot] = useState<WorkspaceSnapshot | null>(null); 69 64 70 65 useEffect(() => { 66 + // eslint-disable-next-line functional/no-let -- per-mount latch 71 67 let handledFirstFire = false; 72 68 73 69 const watcher = opake.watchWorkspaces((snap) => { 74 70 setSnapshot(snap); 75 71 76 - // Only bootstrap once per mount, and only when the keeper isn't 77 - // already loaded. The module-level guard ensures N concurrent 78 - // hook consumers share one in-flight fetch rather than N. 72 + // Bootstrap once per mount when the keeper isn't already loaded. 73 + // bootstrapOnce dedupes concurrent consumers of this hook AND 74 + // isolates per-Opake instances so account switches don't short- 75 + // circuit on a stale in-flight from the previous identity. 79 76 if (!handledFirstFire) { 80 77 handledFirstFire = true; 81 - if (!snap.loaded && !bootstrapPromise) { 82 - bootstrapPromise = opake 83 - .listWorkspaces() 84 - .catch((err: unknown) => { 85 - console.warn("[opake-react] listWorkspaces bootstrap failed:", err); 86 - }) 87 - .finally(() => { 88 - bootstrapPromise = null; 89 - }); 78 + if (!snap.loaded) { 79 + bootstrapOnce(opake, "listWorkspaces", () => opake.listWorkspaces()); 90 80 } 91 81 } 92 82 });