this repo has no description
1
fork

Configure Feed

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

Wire optimistic updates through useDirectory via an overlay layer

useTreeMutation had always accepted an optimisticUpdate transform, but
the transform only wrote to the react-query cache — a surface no
subscription-based consumer reads from. The live UI (FileView et al.)
renders from useDirectory, which subscribes to FileManager.watchDirectory
and reads nothing from react-query. So every "optimistic" delete,
upload, rename, or move was silently invisible, and users waited ~1s
for the SSE echo before seeing their own action.

Introduce an OptimisticOverlay: a per-scope (cabinet or keyring URI)
store of snapshot-transforming patches. OpakeProvider owns one per
Opake instance. useDirectory projects the overlay onto the base
snapshot at render; the projection is a no-op when no patches are
active, so scopes that never see mutations pay nothing.

useTreeMutation pushes the same optimisticUpdate transform into the
overlay on onMutate and releases it after the mutation settles. Error
releases immediately; success delays the release by 2s so the SSE
echo has time to update the base snapshot before the patch drops —
otherwise the UI flickers back to the pre-mutation state in the gap
between PDS write and indexer broadcast.

The existing react-query cache path stays for useTree, which still
reads from that cache.

+340 -8
+152
packages/opake-react/src/__tests__/optimistic-overlay.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import type { DirectoryTreeSnapshot } from "@opake/sdk"; 3 + import { OptimisticOverlay, scopeKey } from "../optimistic-overlay"; 4 + 5 + const emptySnapshot: DirectoryTreeSnapshot = { 6 + rootUri: "at://did:plc:test/app.opake.directory/self", 7 + directories: { 8 + "at://did:plc:test/app.opake.directory/self": { 9 + name: "Cabinet", 10 + entries: [ 11 + { uri: "at://did:plc:test/app.opake.document/a", type: "document" }, 12 + { uri: "at://did:plc:test/app.opake.document/b", type: "document" }, 13 + ], 14 + parentUri: null, 15 + }, 16 + }, 17 + }; 18 + 19 + const ROOT_URI = "at://did:plc:test/app.opake.directory/self"; 20 + const DOC_A = "at://did:plc:test/app.opake.document/a"; 21 + const DOC_B = "at://did:plc:test/app.opake.document/b"; 22 + 23 + function removeDoc(uri: string) { 24 + return (snap: DirectoryTreeSnapshot): DirectoryTreeSnapshot => { 25 + const dir = snap.directories[ROOT_URI]; 26 + if (!dir) return snap; 27 + return { 28 + ...snap, 29 + directories: { 30 + ...snap.directories, 31 + [ROOT_URI]: { ...dir, entries: dir.entries.filter((e) => e.uri !== uri) }, 32 + }, 33 + }; 34 + }; 35 + } 36 + 37 + describe("scopeKey", () => { 38 + it("maps null to 'cabinet'", () => { 39 + expect(scopeKey(null)).toBe("cabinet"); 40 + }); 41 + 42 + it("returns the keyring URI for a workspace", () => { 43 + expect(scopeKey("at://did:plc:test/app.opake.keyring/xyz")).toBe( 44 + "at://did:plc:test/app.opake.keyring/xyz", 45 + ); 46 + }); 47 + }); 48 + 49 + describe("OptimisticOverlay", () => { 50 + it("returns the base snapshot when no patches are active", () => { 51 + const overlay = new OptimisticOverlay(); 52 + expect(overlay.project("cabinet", emptySnapshot)).toBe(emptySnapshot); 53 + }); 54 + 55 + it("applies a single patch", () => { 56 + const overlay = new OptimisticOverlay(); 57 + overlay.apply("cabinet", removeDoc(DOC_A)); 58 + 59 + const projected = overlay.project("cabinet", emptySnapshot); 60 + expect(projected.directories[ROOT_URI]?.entries.map((e) => e.uri)).toEqual([DOC_B]); 61 + }); 62 + 63 + it("composes multiple patches in order", () => { 64 + const overlay = new OptimisticOverlay(); 65 + overlay.apply("cabinet", removeDoc(DOC_A)); 66 + overlay.apply("cabinet", removeDoc(DOC_B)); 67 + 68 + const projected = overlay.project("cabinet", emptySnapshot); 69 + expect(projected.directories[ROOT_URI]?.entries).toEqual([]); 70 + }); 71 + 72 + it("releases a patch and reverts to base", () => { 73 + const overlay = new OptimisticOverlay(); 74 + const release = overlay.apply("cabinet", removeDoc(DOC_A)); 75 + expect(overlay.patchCount("cabinet")).toBe(1); 76 + 77 + release(); 78 + 79 + expect(overlay.patchCount("cabinet")).toBe(0); 80 + expect(overlay.project("cabinet", emptySnapshot)).toBe(emptySnapshot); 81 + }); 82 + 83 + it("keeps remaining patches when one is released", () => { 84 + const overlay = new OptimisticOverlay(); 85 + const releaseA = overlay.apply("cabinet", removeDoc(DOC_A)); 86 + overlay.apply("cabinet", removeDoc(DOC_B)); 87 + 88 + releaseA(); 89 + 90 + expect(overlay.patchCount("cabinet")).toBe(1); 91 + expect(overlay.project("cabinet", emptySnapshot).directories[ROOT_URI]?.entries.map((e) => e.uri)).toEqual([DOC_A]); 92 + }); 93 + 94 + it("release is idempotent", () => { 95 + const overlay = new OptimisticOverlay(); 96 + const release = overlay.apply("cabinet", removeDoc(DOC_A)); 97 + overlay.apply("cabinet", removeDoc(DOC_B)); 98 + 99 + release(); 100 + release(); // second release must not remove an unrelated patch 101 + 102 + expect(overlay.patchCount("cabinet")).toBe(1); 103 + }); 104 + 105 + it("isolates scopes", () => { 106 + const overlay = new OptimisticOverlay(); 107 + overlay.apply("cabinet", removeDoc(DOC_A)); 108 + 109 + expect(overlay.patchCount("cabinet")).toBe(1); 110 + expect(overlay.patchCount("workspace:xyz")).toBe(0); 111 + expect(overlay.project("workspace:xyz", emptySnapshot)).toBe(emptySnapshot); 112 + }); 113 + 114 + it("notifies subscribers on apply and release", () => { 115 + const overlay = new OptimisticOverlay(); 116 + const callback = vi.fn(); 117 + overlay.subscribe("cabinet", callback); 118 + 119 + const release = overlay.apply("cabinet", removeDoc(DOC_A)); 120 + expect(callback).toHaveBeenCalledTimes(1); 121 + 122 + release(); 123 + expect(callback).toHaveBeenCalledTimes(2); 124 + }); 125 + 126 + it("only notifies subscribers of the matching scope", () => { 127 + const overlay = new OptimisticOverlay(); 128 + const cabinetCb = vi.fn(); 129 + const workspaceCb = vi.fn(); 130 + overlay.subscribe("cabinet", cabinetCb); 131 + overlay.subscribe("workspace:xyz", workspaceCb); 132 + 133 + overlay.apply("cabinet", removeDoc(DOC_A)); 134 + 135 + expect(cabinetCb).toHaveBeenCalledTimes(1); 136 + expect(workspaceCb).not.toHaveBeenCalled(); 137 + }); 138 + 139 + it("stops notifying after unsubscribe", () => { 140 + const overlay = new OptimisticOverlay(); 141 + const callback = vi.fn(); 142 + const unsub = overlay.subscribe("cabinet", callback); 143 + 144 + overlay.apply("cabinet", removeDoc(DOC_A)); 145 + expect(callback).toHaveBeenCalledTimes(1); 146 + 147 + unsub(); 148 + 149 + overlay.apply("cabinet", removeDoc(DOC_B)); 150 + expect(callback).toHaveBeenCalledTimes(1); 151 + }); 152 + });
+18 -2
packages/opake-react/src/hooks/use-directory.ts
··· 27 27 import { useCallback, useEffect, useState } from "react"; 28 28 import type { DirectoryTreeSnapshot, DirectoryWatcher, FileManager } from "@opake/sdk"; 29 29 import { useFileManager } from "./use-file-manager"; 30 + import { useOptimisticOverlay } from "../provider"; 31 + import { scopeKey } from "../optimistic-overlay"; 30 32 31 33 interface UseDirectoryResult { 32 34 /** Latest snapshot. null until the first watcher fire. */ ··· 97 99 directoryUri: string | null, 98 100 ): UseDirectoryResult { 99 101 const { fileManager, isReady: fmReady } = useFileManager(keyringUri); 102 + const overlay = useOptimisticOverlay(); 103 + const scope = scopeKey(keyringUri); 100 104 const [commit, setCommit] = useState<Commit | null>(null); 101 105 // Retry is a generation counter that invalidates the effect's deps 102 106 // without touching the component's identity, so a fresh loadTree / ··· 104 108 const [retryGeneration, setRetryGeneration] = useState(0); 105 109 const retry = useCallback(() => setRetryGeneration((g) => g + 1), []); 106 110 111 + // Re-render when the optimistic overlay's patches change. We don't 112 + // need the value — just the signal — so a tick counter is enough. 113 + const [, setOverlayTick] = useState(0); 114 + useEffect(() => { 115 + return overlay.subscribe(scope, () => setOverlayTick((t) => t + 1)); 116 + }, [overlay, scope]); 117 + 107 118 useEffect(() => { 108 119 if (!fmReady || !fileManager) return; 109 120 ··· 184 195 commit !== null && commit.fileManager === fileManager && commit.directoryUri === directoryUri 185 196 ? commit 186 197 : null; 198 + const baseSnapshot = current?.snapshot ?? null; 199 + // Project optimistic patches over the base snapshot. No-op when the 200 + // overlay is empty — project() returns the base unchanged to avoid 201 + // a render churn on scopes that never see mutations. 202 + const snapshot = baseSnapshot ? overlay.project(scope, baseSnapshot) : null; 187 203 return { 188 - snapshot: current?.snapshot ?? null, 189 - isReady: current?.snapshot != null, 204 + snapshot, 205 + isReady: snapshot !== null, 190 206 error: current?.error ?? null, 191 207 resolvedDirectoryUri: current?.resolvedDirectoryUri ?? null, 192 208 retry,
+42 -5
packages/opake-react/src/hooks/use-tree-mutation.ts
··· 3 3 4 4 import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; 5 5 import type { DirectoryTreeSnapshot, FileManager } from "@opake/sdk"; 6 - import { useFileManagerCache } from "../provider"; 6 + import { useFileManagerCache, useOptimisticOverlay } from "../provider"; 7 7 import type { FileManagerCache } from "../file-manager-cache"; 8 + import { scopeKey } from "../optimistic-overlay"; 8 9 import { opakeKeys } from "../keys"; 10 + 11 + // Delay after a mutation settles before releasing the optimistic patch. 12 + // The SSE echo for a just-written record typically arrives within 1s 13 + // from PDS write. Holding the patch past that window means the base 14 + // snapshot reflects the mutation and the patch's projection has become 15 + // a no-op (for filter-style patches) — safe to drop. Dropping earlier 16 + // would briefly reveal the pre-mutation base while the echo catches up. 17 + const OPTIMISTIC_RELEASE_DELAY_MS = 2_000; 9 18 10 19 /** 11 20 * Acquire a FileManager from the provider cache, run a callback, ··· 50 59 * Generic tree mutation hook with FileManager lifecycle, optimistic updates, 51 60 * rollback on error, and query invalidation on settle. 52 61 */ 62 + interface MutationContext { 63 + readonly previous?: DirectoryTreeSnapshot; 64 + readonly releaseOverlay?: () => void; 65 + } 66 + 53 67 export function useTreeMutation<TInput, TResult>( 54 68 options: TreeMutationOptions<TInput, TResult>, 55 69 ): UseMutationResult<TResult, Error, TInput> { 56 70 const cache = useFileManagerCache(); 71 + const overlay = useOptimisticOverlay(); 57 72 const queryClient = useQueryClient(); 58 73 const key = treeKeyFor(options.keyringUri); 74 + const scope = scopeKey(options.keyringUri); 59 75 60 - return useMutation<TResult, Error, TInput, { previous?: DirectoryTreeSnapshot }>({ 76 + return useMutation<TResult, Error, TInput, MutationContext>({ 61 77 mutationFn: (input) => 62 78 withFileManager(cache, options.keyringUri, (fm) => options.mutationFn(fm, input)), 63 79 ··· 66 82 // Narrow once: we're inside the `options.optimisticUpdate` truthy 67 83 // branch but TS can't flow that into an async callback body. 68 84 const apply = options.optimisticUpdate!; 85 + 86 + // Legacy queryCache path — still needed for `useTree` consumers 87 + // (deprecated but kept for invalidation semantics). 69 88 await queryClient.cancelQueries({ queryKey: key }); 70 89 const previous = queryClient.getQueryData<DirectoryTreeSnapshot>(key); 71 - 72 90 if (previous) { 73 91 queryClient.setQueryData<DirectoryTreeSnapshot>(key, (old) => 74 92 old ? apply(old, input) : old, 75 93 ); 76 94 } 77 95 78 - return { previous }; 96 + // Subscription consumers (useDirectory, which is what the live 97 + // UI actually renders from) read from the optimistic overlay. 98 + // Push the same transform there so the change appears within 99 + // the current render instead of waiting ~1s for the SSE echo. 100 + const releaseOverlay = overlay.apply(scope, (snap) => apply(snap, input)); 101 + 102 + return { previous, releaseOverlay }; 79 103 } 80 104 : undefined, 81 105 ··· 83 107 if (context?.previous) { 84 108 queryClient.setQueryData(key, context.previous); 85 109 } 110 + // Release the overlay immediately on error: there's no server-side 111 + // state to wait for, and leaving the patch on screen would show the 112 + // user a mutation that never happened. 113 + context?.releaseOverlay?.(); 86 114 }, 87 115 88 - onSettled: () => { 116 + onSettled: (_data, error, _input, context) => { 89 117 void queryClient.invalidateQueries({ queryKey: key }); 90 118 // Metadata is keyed per-directory and useDirectoryMetadata has a 91 119 // separate cache that doesn't observe tree mutations. Rather than ··· 93 121 // all metadata prefixes — non-active directories are inert refetches 94 122 // and keepPreviousData suppresses loading flicker on the active one. 95 123 void queryClient.invalidateQueries({ queryKey: ["opake", "metadata"] }); 124 + 125 + // On success, hold the overlay patch through the SSE echo window so 126 + // the UI doesn't flicker back to pre-mutation state in the gap 127 + // between PDS write and indexer broadcast. onError already released 128 + // synchronously. 129 + if (!error && context?.releaseOverlay) { 130 + const release = context.releaseOverlay; 131 + setTimeout(release, OPTIMISTIC_RELEASE_DELAY_MS); 132 + } 96 133 }, 97 134 }); 98 135 }
+104
packages/opake-react/src/optimistic-overlay.ts
··· 1 + // Per-scope optimistic overlay for directory-tree mutations. 2 + // 3 + // useDirectory subscribes to FileManager.watchDirectory for the base 4 + // snapshot, which updates when SSE events arrive. That delivery has a 5 + // ~1s PDS-write-to-SSE-echo latency, during which a user's mutation 6 + // has already succeeded on the server but the local UI still shows 7 + // the pre-mutation tree. This overlay bridges that gap: mutation hooks 8 + // push a patch (delete this entry, insert this placeholder) on 9 + // onMutate, and useDirectory applies the patch onto the base snapshot 10 + // at render time. The patch is released ~2s after the mutation settles 11 + // so the SSE echo has time to update the base — after which the patch 12 + // would be redundant at best and a double-display at worst (for 13 + // non-idempotent patches like uploads). 14 + // 15 + // Scope keys are (keyringUri | "cabinet"). All patches for a scope 16 + // compose as a left-fold; order of application matches order of apply(). 17 + 18 + import type { DirectoryTreeSnapshot } from "@opake/sdk"; 19 + 20 + type Transform = (snapshot: DirectoryTreeSnapshot) => DirectoryTreeSnapshot; 21 + 22 + interface OptimisticPatch { 23 + readonly id: symbol; 24 + readonly transform: Transform; 25 + } 26 + 27 + /** 28 + * Resolve a stable string key for an overlay scope. Callers pass 29 + * either a workspace keyring URI or null for the cabinet; both 30 + * forms map to distinct string keys. 31 + */ 32 + export function scopeKey(keyringUri: string | null): string { 33 + return keyringUri ?? "cabinet"; 34 + } 35 + 36 + export class OptimisticOverlay { 37 + private readonly subscribers = new Map<string, Set<() => void>>(); 38 + private readonly patches = new Map<string, readonly OptimisticPatch[]>(); 39 + 40 + /** 41 + * Subscribe to overlay changes for a scope. The callback fires 42 + * whenever a patch is added or released. Returns an unsubscribe 43 + * function. 44 + */ 45 + subscribe(scope: string, callback: () => void): () => void { 46 + // eslint-disable-next-line functional/no-let -- lazy-init slot 47 + let set = this.subscribers.get(scope); 48 + if (!set) { 49 + set = new Set(); 50 + this.subscribers.set(scope, set); 51 + } 52 + const bucket = set; 53 + bucket.add(callback); 54 + return () => { 55 + bucket.delete(callback); 56 + if (bucket.size === 0) this.subscribers.delete(scope); 57 + }; 58 + } 59 + 60 + /** 61 + * Push a patch onto the scope. Returns a release function that 62 + * removes the patch when called. Release is idempotent. 63 + */ 64 + apply(scope: string, transform: Transform): () => void { 65 + const patch: OptimisticPatch = { id: Symbol("patch"), transform }; 66 + const current = this.patches.get(scope) ?? []; 67 + this.patches.set(scope, [...current, patch]); 68 + this.notify(scope); 69 + 70 + // eslint-disable-next-line functional/no-let -- single-shot latch 71 + let released = false; 72 + return () => { 73 + if (released) return; 74 + released = true; 75 + const latest = this.patches.get(scope) ?? []; 76 + const filtered = latest.filter((p) => p.id !== patch.id); 77 + if (filtered.length === 0) this.patches.delete(scope); 78 + else this.patches.set(scope, filtered); 79 + this.notify(scope); 80 + }; 81 + } 82 + 83 + /** 84 + * Apply all active patches for a scope to a base snapshot. Returns 85 + * the base unchanged when no patches are active — avoids rebuilding 86 + * the snapshot object on every render. 87 + */ 88 + project(scope: string, base: DirectoryTreeSnapshot): DirectoryTreeSnapshot { 89 + const patches = this.patches.get(scope); 90 + if (!patches || patches.length === 0) return base; 91 + return patches.reduce((acc, p) => p.transform(acc), base); 92 + } 93 + 94 + /** Test helper: number of active patches for a scope. */ 95 + patchCount(scope: string): number { 96 + return this.patches.get(scope)?.length ?? 0; 97 + } 98 + 99 + private notify(scope: string): void { 100 + const subs = this.subscribers.get(scope); 101 + if (!subs) return; 102 + for (const callback of subs) callback(); 103 + } 104 + }
+24 -1
packages/opake-react/src/provider.tsx
··· 21 21 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 22 22 import type { Opake } from "@opake/sdk"; 23 23 import { FileManagerCache } from "./file-manager-cache"; 24 + import { OptimisticOverlay } from "./optimistic-overlay"; 24 25 25 26 const OpakeContext = createContext<Opake | null>(null); 26 27 const FileManagerCacheContext = createContext<FileManagerCache | null>(null); 28 + const OptimisticOverlayContext = createContext<OptimisticOverlay | null>(null); 27 29 28 30 /** 29 31 * Access the Opake instance from context. ··· 59 61 return cache; 60 62 } 61 63 64 + /** 65 + * Access the shared OptimisticOverlay. Internal — consumed by 66 + * `useTreeMutation` (patches) and `useDirectory` (projection). 67 + * 68 + * @internal 69 + */ 70 + export function useOptimisticOverlay(): OptimisticOverlay { 71 + const overlay = useContext(OptimisticOverlayContext); 72 + if (!overlay) { 73 + throw new Error("useOptimisticOverlay must be used within an OpakeProvider"); 74 + } 75 + return overlay; 76 + } 77 + 62 78 interface OpakeProviderProps { 63 79 /** An initialized Opake instance (or Comlink proxy to one in a worker). */ 64 80 readonly opake: Opake; ··· 122 138 // cache and let the old one GC naturally. 123 139 const cache = useMemo(() => new FileManagerCache(opake), [opake]); 124 140 141 + // Optimistic overlay is also tied to the opake instance — account 142 + // switches start with an empty overlay so stale patches from the 143 + // previous identity don't project onto the next user's trees. 144 + const overlay = useMemo(() => new OptimisticOverlay(), [opake]); 145 + 125 146 // Dispose cached FileManagers when the cache is replaced or the 126 147 // provider unmounts. 127 148 useEffect(() => { ··· 162 183 return ( 163 184 <QueryClientProvider client={activeClient}> 164 185 <OpakeContext value={opake}> 165 - <FileManagerCacheContext value={cache}>{children}</FileManagerCacheContext> 186 + <FileManagerCacheContext value={cache}> 187 + <OptimisticOverlayContext value={overlay}>{children}</OptimisticOverlayContext> 188 + </FileManagerCacheContext> 166 189 </OpakeContext> 167 190 </QueryClientProvider> 168 191 );