diff --git a/desktop/src/shared/api/relayQueryInvalidation.test.mjs b/desktop/src/shared/api/relayQueryInvalidation.test.mjs new file mode 100644 index 0000000000..b163721c67 --- /dev/null +++ b/desktop/src/shared/api/relayQueryInvalidation.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isRelayDependentQueryKey } from "./relayQueryInvalidation.ts"; + +test("relay invalidation includes relay-backed channel and profile queries", () => { + for (const queryKey of [ + ["channels"], + ["channels", "channel-1", "members"], + ["channel-messages", "channel-1"], + ["thread-replies", "channel-1", "root-1"], + ["forum-posts", "channel-1"], + ["home-feed"], + ["users-batch", "alice"], + ["presence", "alice"], + ["user-status", "alice"], + ["relay-agents"], + ["relayMembers"], + ["archivedIdentities"], + ["oaOwner", "alice"], + ]) { + assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/")); + } +}); + +test("relay invalidation includes social/workflow relay queries", () => { + for (const queryKey of [ + ["global-notes"], + ["liked-notes", "alice"], + ["pulse-reactions", "note-1"], + ["workflows", "channel-1"], + ["workflows-all", "channel-1"], + ["workflow-runs", "workflow-1"], + ["run-approvals", "workflow-1", "run-1"], + ["reminders", "alice"], + ["custom-emoji"], + ]) { + assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/")); + } +}); + +test("relay invalidation excludes local Tauri and disk-only query roots", () => { + for (const queryKey of [ + ["identity"], + ["managed-agents"], + ["personas"], + ["teams"], + ["acp-runtimes"], + ["backend-providers"], + ["managed-agent-log", "agent-1", 200], + ["workspace-icon", "wss://relay.example"], + ["agent-memory", "agent-1"], + ]) { + assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/")); + } +}); + +test("relay invalidation separates relay project queries from local repo work", () => { + for (const queryKey of [ + ["projects"], + ["project", "project-1"], + ["project", "project-1", "issues"], + ["project", "project-1", "pull-requests"], + ["projects", "issues", ["project-1"]], + ["projects", "activity-summaries", ["addr-1"]], + ]) { + assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/")); + } + + for (const queryKey of [ + ["project", "project-1", "repo-state"], + ["project", "project-1", "repo-snapshot", "main"], + ["project", "project-1", "repo-diff", "main"], + ["project", "project-1", "local-repo-diff"], + ["project", "project-1", "commit-diff", "remote"], + ["projects", "local-repositories", "default"], + ["projects", "repo-snapshots", "default", ["project-1"]], + ]) { + assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/")); + } +}); diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts new file mode 100644 index 0000000000..e07992184d --- /dev/null +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -0,0 +1,84 @@ +const RELAY_QUERY_ROOTS = new Set([ + "archivedIdentities", + "channel-canvas", + "channel-messages", + "channels", + "contact-list", + "custom-emoji", + "custom-emoji-own", + "forum-posts", + "forum-thread", + "global-notes", + "home-feed", + "liked-notes", + "my-notes", + "myRelayMembership", + "oaOwner", + "presence", + "profile", + "pulse-note", + "pulse-reactions", + "pulse-timeline", + "relay-agents", + "relayMembers", + "reminders", + "run-approvals", + "search-messages", + "thread-replies", + "user-profile", + "user-search", + "user-status", + "users-batch", + "workflow", + "workflow-runs", + "workflows", + "workflows-all", +]); + +const RELAY_PROJECT_QUERY_PARTS = new Set([ + "activity-summaries", + "issues", + "pull-requests", +]); + +const LOCAL_PROJECT_QUERY_PARTS = new Set([ + "commit-diff", + "local-repo-diff", + "local-repo-snapshot", + "local-repositories", + "repo-diff", + "repo-snapshot", + "repo-state", + "repo-sync-status", +]); + +function isRelayDependentProjectQueryKey(queryKey: readonly unknown[]) { + if (queryKey[0] === "projects") { + const scope = queryKey[1]; + if (scope === undefined) return true; + if (typeof scope !== "string") return false; + if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false; + return RELAY_PROJECT_QUERY_PARTS.has(scope); + } + + if (queryKey[0] === "project") { + const scope = queryKey[2]; + if (scope === undefined) return true; + if (typeof scope !== "string") return false; + if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false; + return RELAY_PROJECT_QUERY_PARTS.has(scope); + } + + return false; +} + +export function isRelayDependentQueryKey(queryKey: readonly unknown[]) { + const root = queryKey[0]; + if (typeof root !== "string") return false; + if (RELAY_QUERY_ROOTS.has(root)) return true; + return isRelayDependentProjectQueryKey(queryKey); +} + +export function isRelayDependentQuery(query: { queryKey: readonly unknown[] }) { + return isRelayDependentQueryKey(query.queryKey); +} diff --git a/desktop/src/shared/api/useReconnectRelay.ts b/desktop/src/shared/api/useReconnectRelay.ts index 3f4e41ecc9..e892ca5c62 100644 --- a/desktop/src/shared/api/useReconnectRelay.ts +++ b/desktop/src/shared/api/useReconnectRelay.ts @@ -16,6 +16,7 @@ import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { relayClient } from "@/shared/api/relayClient"; +import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { relayReconnectController } from "@/shared/api/relayReconnectController"; function buildDeps(onSuccess: () => void, onBackstop: () => void) { @@ -59,12 +60,14 @@ export function useReconnectRelay(): { onSuccessRef.current = React.useCallback(() => { // Defer query invalidation so callers render the recovered state first. window.setTimeout(() => { - void queryClient.invalidateQueries().catch((err) => { - console.error( - "[useReconnectRelay] failed to refresh queries after reconnect:", - err, - ); - }); + void queryClient + .invalidateQueries({ predicate: isRelayDependentQuery }) + .catch((err) => { + console.error( + "[useReconnectRelay] failed to refresh queries after reconnect:", + err, + ); + }); }, 0); }, [queryClient]); diff --git a/desktop/src/shared/api/useRelayAutoHeal.ts b/desktop/src/shared/api/useRelayAutoHeal.ts index 18fb1aaf59..af0cbe55a7 100644 --- a/desktop/src/shared/api/useRelayAutoHeal.ts +++ b/desktop/src/shared/api/useRelayAutoHeal.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { ConnectionState } from "@/shared/api/relayClientShared"; +import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { isRelayConnectionDegraded, useRelayConnection, @@ -82,12 +83,12 @@ export class RelayAutoHealScheduler { /** * Auto-heal: when the connection recovers from a degraded state, invalidate - * all queries so errored queries (e.g. messages, which don't poll) refetch - * automatically without requiring a manual reconnect action. + * relay-dependent queries so errored queries (e.g. messages, which don't poll) + * refetch automatically without requiring a manual reconnect action. * * Rate-limited to prevent a flappy connection (e.g. VPN toggling) from - * firing an unfiltered invalidation — ~20-40 requests across active queries - * with retry:1 — every time the relay briefly recovers. + * firing a relay-wide invalidation across active queries with retry:1 every + * time the relay briefly recovers. * * When a recovery is suppressed by the rate limiter (an earlier flap consumed * the budget), a deferred heal is scheduled for the remaining window so the @@ -101,7 +102,10 @@ export function useRelayAutoHeal(): void { if (schedulerRef.current === null) { schedulerRef.current = new RelayAutoHealScheduler( - () => void queryClient.invalidateQueries(), + () => + void queryClient.invalidateQueries({ + predicate: isRelayDependentQuery, + }), AUTO_HEAL_MIN_INTERVAL_MS, window.setTimeout.bind(window), window.clearTimeout.bind(window),