Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/hooks/__tests__/use-endorsement-graph.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ vi.mock("@/lib/atproto/workspace", () => ({
}),
}))

/** Handle/avatar resolution; a test can swap in a pending one. */
let loadResolvedProfile: (did: string) => Promise<unknown> = async () => null

vi.mock("@/lib/atproto/resolve-did-batch", () => ({
loadResolvedProfile: async () => null,
loadResolvedProfile: (did: string) => loadResolvedProfile(did),
}))

// 120 endorsements between disjoint pairs -> 240 participant DIDs -> two
Expand Down Expand Up @@ -85,6 +88,7 @@ async function freshHook() {
beforeEach(() => {
actorCalls = []
fetchCalls = []
loadResolvedProfile = async () => null
stubIndexerFetch()
})

Expand Down Expand Up @@ -156,3 +160,40 @@ describe("useEndorsementGraph — profile chunk resolution", () => {
)
})
})

describe("useEndorsementGraph — handle resolution", () => {
it("shows the graph before handles resolve, then fills them in place", async () => {
// Hold every DID resolution until the test releases it.
let release: () => void = () => {}
const gate = new Promise<void>((r) => {
release = r
})
loadResolvedProfile = async (did) => {
await gate
return { did, handle: `${did.slice(8)}.test`, avatar: null }
}

const useEndorsementGraph = await freshHook()
const { result } = renderHook(() => useEndorsementGraph())
await waitFor(() => expect(actorCalls).toHaveLength(3))
for (const call of actorCalls) {
call.resolve(call.dids.map((did) => ({ did, displayName: null, avatarUrl: null })))
}

// Drawn while every resolution is still pending.
await waitFor(() => expect(result.current.isLoading).toBe(false))
const early = result.current.graph!
expect(early.nodes).toHaveLength(EDGE_COUNT * 2)
const node = early.nodes.find((n) => n.id === "did:plc:s5")!
expect(node.handle).toBeNull()

release()
await waitFor(() => expect(result.current.graph).not.toBe(early))
const done = result.current.graph!
const filled = done.nodes.find((n) => n.id === "did:plc:s5")!
expect(filled.handle).toBe("s5.test")
// Same object: react-force-graph keeps the layout position on it.
expect(filled).toBe(node)
expect(done.nodes).not.toBe(early.nodes)
})
})
82 changes: 60 additions & 22 deletions src/hooks/use-endorsement-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,12 @@ async function fetchAllAwards(
return { nodes: out, truncated }
}

async function buildGraph(signal?: AbortSignal): Promise<EndorsementGraph> {
async function buildGraph(
signal?: AbortSignal,
/** Called once with the complete graph before handles and avatars are
* resolved; the returned graph has them filled in. */
onPartial?: (graph: EndorsementGraph) => void,
): Promise<EndorsementGraph> {
// One paginated scan per badge type; the two run concurrently.
const [endorsementScan, awardScan] = await Promise.all([
fetchAllAwards("endorsement", signal),
Expand Down Expand Up @@ -323,6 +328,30 @@ async function buildGraph(signal?: AbortSignal): Promise<EndorsementGraph> {
return { source: e.source, target: e.target, kind: e.kind, mutual: reverse }
})

const identity = (did: string): Pick<GraphNode, "handle" | "displayName" | "avatarUrl"> => {
const p = profiles.get(did)
const fb = fallback.get(did)
return {
// Handle only comes from the issuer join; keep it even when the
// actor-profile lookup missed this DID.
handle: p?.handle ?? handleByDid.get(did) ?? null,
displayName: p?.displayName ?? fb?.displayName ?? null,
avatarUrl: p?.avatarUrl ?? fb?.avatarUrl ?? null,
}
}
const nodes: GraphNode[] = [...allDids].map((did) => ({
id: did,
...identity(did),
given: givenCount.get(did) ?? 0,
received: receivedCount.get(did) ?? 0,
mutual: mutualNeighbours.get(did)?.size ?? 0,
}))
const graph = { nodes, links, totalEndorsements, totalAwards, mutualPairs, truncated }

// Everything below only fills in handles and avatars, which takes
// several seconds on a large network, so the graph can be drawn now.
onPartial?.(graph)

// The actor-profile index has no handle field and incomplete avatar
// coverage, while the award join only carries handles for issuers. Fill
// the gaps with the batched DID resolver (handle + displayName + avatar
Expand Down Expand Up @@ -353,23 +382,11 @@ async function buildGraph(signal?: AbortSignal): Promise<EndorsementGraph> {
})
}

const nodes: GraphNode[] = [...allDids].map((did) => {
const p = profiles.get(did)
const fb = fallback.get(did)
return {
id: did,
// Handle only comes from the issuer join; keep it even when the
// actor-profile lookup missed this DID.
handle: p?.handle ?? handleByDid.get(did) ?? null,
displayName: p?.displayName ?? fb?.displayName ?? null,
avatarUrl: p?.avatarUrl ?? fb?.avatarUrl ?? null,
given: givenCount.get(did) ?? 0,
received: receivedCount.get(did) ?? 0,
mutual: mutualNeighbours.get(did)?.size ?? 0,
}
})

return { nodes, links, totalEndorsements, totalAwards, mutualPairs, truncated }
// Update the drawn node objects in place: react-force-graph keeps each
// node's layout position on the object itself, so new objects would
// restart the layout. The new array still tells React the data changed.
for (const node of nodes) Object.assign(node, identity(node.id))
return { ...graph, nodes: [...nodes] }
Comment on lines +388 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Names arriving reset the zoom

Replacing the nodes array makes EndorsementGraph rebuild data. Its layout effect calls d3ReheatSimulation(), and onEngineStop then calls zoomToFit(). Users who zoom into the early graph while names load get zoomed back out when loading finishes. Keeping the same node objects preserves their positions, but does not prevent this restart.

Refresh labels separately from the graph structure so updates to names and avatars do not restart the layout or move the camera.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/use-endorsement-graph.ts
Line: 388-389

Comment:
**Names arriving reset the zoom**

Replacing the `nodes` array makes `EndorsementGraph` rebuild `data`. Its layout effect calls `d3ReheatSimulation()`, and `onEngineStop` then calls `zoomToFit()`. Users who zoom into the early graph while names load get zoomed back out when loading finishes. Keeping the same node objects preserves their positions, but does not prevent this restart.

Refresh labels separately from the graph structure so updates to names and avatars do not restart the layout or move the camera.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}

// --------------------------------------------------------------------------
Expand All @@ -380,6 +397,10 @@ async function buildGraph(signal?: AbortSignal): Promise<EndorsementGraph> {
const STALE_MS = 5 * 60 * 1000
let cache: { data: EndorsementGraph; fetchedAt: number } | null = null
let inFlight: Promise<EndorsementGraph> | null = null
// The in-flight scan's graph before handle resolution, for mounts that
// join the scan after it was emitted, plus the mounts waiting for it.
let partial: EndorsementGraph | null = null
const partialListeners = new Set<(graph: EndorsementGraph) => void>()

export function useEndorsementGraph(): {
graph: EndorsementGraph | null
Expand All @@ -404,18 +425,34 @@ export function useEndorsementGraph(): {
}
setIsLoading(true)
setError(null)
// Draw the graph as soon as its structure is known; handles and
// avatars follow when the scan completes.
const showPartial = (data: EndorsementGraph) => {
if (cancelled) return
setGraph(data)
setIsLoading(false)
}
partialListeners.add(showPartial)
try {
// Coalesce concurrent mounts onto one network scan. The scan
// itself isn't tied to a single component's signal so a remount
// doesn't kill an in-flight fetch others are awaiting.
if (!inFlight) {
inFlight = buildGraph().then((data) => {
inFlight = buildGraph(undefined, (data) => {
partial = data
for (const listener of partialListeners) listener(data)
}).then((data) => {
cache = { data, fetchedAt: Date.now() }
return data
})
inFlight.finally(() => {
inFlight = null
})
inFlight
.finally(() => {
inFlight = null
partial = null
})
.catch(() => {})
} else if (partial) {
showPartial(partial)
}
const data = await inFlight
if (cancelled || controller.signal.aborted) return
Expand All @@ -425,6 +462,7 @@ export function useEndorsementGraph(): {
if (err instanceof DOMException && err.name === "AbortError") return
setError(err instanceof Error ? err.message : "Failed to load endorsement graph")
} finally {
partialListeners.delete(showPartial)
if (!cancelled) setIsLoading(false)
}
}
Expand Down
38 changes: 38 additions & 0 deletions src/lib/atproto/__tests__/resolve-did-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,44 @@ describe("resolve-did batch coalescer", () => {
expect(bodyOf(1).identities).toHaveLength(10)
})

it("sends up to three chunks of one flush at a time", async () => {
const pending: ((r: Response) => void)[] = []
authFetch.mockImplementation(
() => new Promise<Response>((resolve) => pending.push(resolve)),
)
const ids = Array.from(
{ length: 200 },
(_, i) => `did:plc:${i.toString().padStart(24, "0")}`,
)
const ps = ids.map((id) => loadResolvedProfile(id))
await vi.advanceTimersByTimeAsync(20)

// Four chunks of 50: three in flight, the fourth waits for a slot.
expect(authFetch).toHaveBeenCalledTimes(3)
pending[0](jsonResponse({ results: {} }))
await vi.advanceTimersByTimeAsync(0)
expect(authFetch).toHaveBeenCalledTimes(4)

for (const resolve of pending.slice(1)) resolve(jsonResponse({ results: {} }))
await Promise.all(ps)
})

it("does not send queued chunks into a 429 cooldown", async () => {
authFetch.mockResolvedValueOnce(jsonResponse({ error: "x" }, 429))
authFetch.mockResolvedValue(jsonResponse({ results: {} }))
const ids = Array.from(
{ length: 200 },
(_, i) => `did:plc:${i.toString().padStart(24, "0")}`,
)
const ps = ids.map((id) => loadResolvedProfile(id))
await vi.advanceTimersByTimeAsync(20)
const results = await Promise.all(ps)

// The fourth chunk degraded to null instead of being sent.
expect(authFetch).toHaveBeenCalledTimes(3)
expect(results.slice(150).every((r) => r === null)).toBe(true)
})

it("defers the next batch until the 429 cooldown lapses", async () => {
authFetch.mockResolvedValueOnce(jsonResponse({ error: "x" }, 429))
const first = loadResolvedProfile("did:plc:a")
Expand Down
20 changes: 19 additions & 1 deletion src/lib/atproto/resolve-did-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export interface ResolvedDidResult {
const BATCH_WINDOW_MS = 16
/** Mirror the server's per-request cap; larger sets chunk into N POSTs. */
const MAX_BATCH = 50
/** Chunks of one flush sent concurrently. The server charges its rate
* limit per identity (600/min), so this changes speed, not volume. */
const CHUNK_CONCURRENCY = 3
/** Re-allow a failed identity after this long so views self-heal. */
const NEGATIVE_TTL_MS = 30_000
/** Pause flushing for this long after a 429, then resume. */
Expand Down Expand Up @@ -109,9 +112,24 @@ async function flush(): Promise<void> {
// accumulate in a fresh queue and schedule their own flush.
const drained = queue
queue = []
const chunks: QueueEntry[][] = []
for (let i = 0; i < drained.length; i += MAX_BATCH) {
await sendChunk(drained.slice(i, i + MAX_BATCH))
chunks.push(drained.slice(i, i + MAX_BATCH))
}
// A few chunks in flight at once: each takes seconds server-side, so a
// large set (the endorsement graph resolves hundreds) would otherwise
// wait for every chunk in turn.
const worker = async () => {
for (let chunk = chunks.shift(); chunk; chunk = chunks.shift()) {
// A 429 on a parallel chunk: degrade the rest to fallbacks instead
// of sending into the cooldown. The negative TTL retries them later.
if (Date.now() < cooldownUntil) failChunk(chunk)
else await sendChunk(chunk)
}
}
await Promise.all(
Array.from({ length: Math.min(CHUNK_CONCURRENCY, chunks.length) }, worker),
)
}

async function sendChunk(chunk: QueueEntry[]): Promise<void> {
Expand Down
51 changes: 51 additions & 0 deletions src/lib/groups/__tests__/signed-out-loading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, afterEach } from "vitest"
import { renderHook, cleanup, waitFor } from "@testing-library/react"
import React from "react"

/**
* Signed out, the groups pages (/groups/create, /groups/import) wait on
* `useOrg().isLoading` and `useOrgCreationLimit().isChecking` before
* rendering anything. Both must settle to false, or the page sits on its
* spinner forever instead of showing the sign-in prompt.
*/

vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ isAuthenticated: false, isLoading: false, did: null }),
}))

const resolveGroups = vi.fn()
const getSelfCreatedOrgCount = vi.fn()
vi.mock("@/lib/groups/api", () => ({
resolveGroups: (...a: unknown[]) => resolveGroups(...a),
getSelfCreatedOrgCount: (...a: unknown[]) => getSelfCreatedOrgCount(...a),
}))

import { OrgProvider, useOrg } from "../org-context"
import { useOrgCreationLimit } from "../use-org-limit"

function wrapper({ children }: { children: React.ReactNode }) {
return <OrgProvider>{children}</OrgProvider>
}

afterEach(() => {
cleanup()
window.sessionStorage.clear()
})

describe("groups loading state when signed out", () => {
it("the org list settles without fetching", async () => {
const { result } = renderHook(() => useOrg(), { wrapper })

await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.groups).toEqual([])
expect(resolveGroups).not.toHaveBeenCalled()
})

it("the creation-limit check settles without fetching", async () => {
const { result } = renderHook(() => useOrgCreationLimit(), { wrapper })

await waitFor(() => expect(result.current.isChecking).toBe(false))
expect(result.current.limitReached).toBe(false)
expect(getSelfCreatedOrgCount).not.toHaveBeenCalled()
})
})
3 changes: 3 additions & 0 deletions src/lib/groups/org-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export function OrgProvider({ children }: { children: React.ReactNode }) {
setSelfGroup(null)
setActiveOrg(null)
persistOrg(null)
// Nothing to load when signed out; without this, consumers that
// wait on `isLoading` (groups/create, groups/import) spin forever.
setIsLoading(false)
return
}
setIsLoading(true)
Expand Down
4 changes: 3 additions & 1 deletion src/lib/groups/use-org-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function useOrgCreationLimit() {

return {
selfCreatedCount,
isChecking: isChecking || orgsLoading,
// With no signed-in DID there is nothing to check, even when the org
// list had already settled at mount and the key above never changed.
isChecking: orgsLoading || (!!did && isChecking),
limitReached: selfCreatedCount !== null && selfCreatedCount >= MAX_SELF_CREATED_ORGS,
}
}
Loading