Files
buzz/desktop/src/shared/api/tauriChannels.ts
Will Pfleger f35930104b fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels (#5599)
Desktop input latency regressed sharply for users on v0.5.9 and worsened
on latest main: multi-second stalls when clicking back into the app,
slow fresh boots, intermittent lockups, and scroll/mouse degradation.
Reverting to `119a84897` (pre-0.5.9) was confirmed to resolve it,
isolating the regression to that range. Profiling a live production
renderer plus a commit-level audit of the range found three independent,
additive causes — fixed here — plus a long-standing `get_channels` cost
that made every remaining refetch expensive, also addressed here.

## 1. Focus-return refetch storm (`refetchOnWindowFocus`)

#5490 wired TanStack's `focusManager` to app focus and flipped ~20 query
sites to `refetchOnWindowFocus: true`. A focus return after >60s away
fires them all within milliseconds — and a click into an unfocused
window *is* a focus return, so the burst runs before the click is
processed. That is the "click into the composer, wait 5 seconds"
symptom, and it also explains why mouse input feels worse than keyboard
(clicks arrive with focus transitions; typing happens while already
focused). A 5-second `sample` of a live production renderer caught a
single window activity-state transition consuming ~1.25s of main-thread
time, dominated by `JSON.parse` in the focus listener's microtask drain.

#5535 already established the fix pattern but applied it to only two
families (channels, home-feed). This PR extends the same 5-minute
`staleTime` discipline to the remaining families: pulse (×5), workflows
(×4), agents (×4), forum (×2), presence, user-status, custom-emoji,
channel-templates, and the persona catalog. Polling cadences and
push-invalidation paths are untouched — interval refetches and
`invalidateQueries` both bypass `staleTime`, so live-update behavior is
unchanged. Each gated family exports its focus-refetch policy as an
options object that the production hook spreads into `useQuery`, and a
`focusRefetchPolicy.test.mjs` drives a `QueryObserver` with that same
production object — locking the policy behaviorally (fresh focus return
→ 0 fetches; stale → refetch) and failing if a hook's
`staleTime`/`refetchOnWindowFocus` wiring drifts.

Four families deliberately keep tighter freshness, all surfaces where
the 5-minute gate would suppress the only refresh path and none of which
feed the app-wide storm: `repo-sync-status` keeps its fresh focus
refetch (its inline comment documents the "committed in a terminal,
switched back to the app" flow as intended); the workflow-runs list
stale-gates at 10s because a remotely-started run has no push
invalidation and its conditional 1s poll is off while the cache shows no
active runs; the workflow list queries (`useChannelWorkflowsQuery` and
the all-channels aggregate) stale-gate at 10s because they have no poll
and no relay subscription, and mutation-driven invalidation only covers
this renderer — remote workflow creates/edits/deletes surface only via
focus refetch; and the managed-agent log stale-gates at one poll tick
(30s) so returning to a live agent log refreshes immediately. Run
approvals keep the 5-minute gate under
`RUN_APPROVALS_FOCUS_STALE_TIME_MS` — their focused 10s poll already
covers freshness.

## 2. Synchronous localStorage sweep on the boot/focus path

#5453's stale-cache sweep synchronously `getItem` + `JSON.parse`s every
whitelisted localStorage entry on the main thread (multi-MB on seasoned
profiles), scheduled with a `requestIdleCallback` timeout of 1.5s that
guaranteed it landed mid-boot, and re-armed on every hidden→visible
transition — stacking it onto the exact moment the focus storm fires.
#5454's `trimSelfProfileCaches()` additionally scanned every
localStorage key on every `writeSelfProfileCache()` call (which fires
per relay self-profile delivery at boot).

Now: the first sweep waits `BOOT_SWEEP_FLOOR_MS` (30s) after startup,
the scan is time-sliced across idle callbacks, and the visibility
trigger is removed — boot-delayed plus hourly still covers the 14-day
TTL contract. The sliced sweep re-checks staleness immediately before
each removal (a key rewritten fresh mid-sweep survives), isolates
per-key storage errors so one bad entry can't strand the rest of the
snapshot, defers oversized values once rather than parsing them on a
zero-budget slice, guarantees forward progress on timeout-fired
callbacks, and cancels its scheduled slice when stopped. The profile
trim keeps a lazily-initialized memoized key count so the common
under-cap write is O(1); the full parse scan runs only when the count
exceeds a cap, resyncs if external deletions made it stale, and a failed
scan skips the trim instead of aborting the write. Sweep semantics
(rules, TTLs, eviction) are unchanged, and tests cover the scheduling,
slice-progress, error-isolation, defer-once, and trim short-circuit
behaviors.

## 3. The macOS window was never opaque

#5478's glass appearance is correctly opt-in at the CSS layer, but the
compositor cost was baked in deeper than its native `on_webview_ready`
transparency call: the main window is declared `"transparent": true` in
`tauri.conf.json` (added for the original glass work in #1671), which
makes tao call `NSWindow.setOpaque(false)` at creation and resolve every
later `set_background_color(None)` to `clearColor` — and no runtime
`setOpaque(true)` path exists through tauri, while wry's runtime
background setter can only force the WKWebView's `drawsBackground` off,
never back on. So "restore the platform default" was unreachable: every
launch, glass or not, ran with a non-opaque NSWindow, defeating
WindowServer's opaque-window compositing fast path and forcing full
window compositing every frame — compounded by the existing
`backdrop-blur` chrome overlapping the scrolling timeline. This matches
the compositor-shaped symptoms (scroll and pointer input degrading
first).

The window is now created opaque (`"transparent": false`) and the
NSWindow layer is never made transparent at runtime. Glass never needed
a transparent window: behind-window `NSVisualEffectView` vibrancy
renders inside opaque windows (this is how Finder and Notes draw vibrant
sidebars); it only requires a transparent WKWebView canvas, which the
`set_window_vibrancy` enable path already establishes at runtime
(`macos-private-api` compiles that in independent of the window flag).
Enabling glass installs the vibrancy layer and then makes only the
webview canvas see-through; disabling clears the vibrancy layer — the
canvas may stay non-drawing afterwards (wry's flag is one-way at
runtime), which is harmless because glass-off CSS paints fully opaque
above an always-opaque NSWindow. The boot-path first-frame backing
writes touch only the NSWindow backing color and are therefore inert to
glass state regardless of how they order against the `ThemeProvider`'s
vibrancy call on a persisted-glass-on cold boot. Glass-off users (the
default) get an end-to-end opaque window from boot for the first time.

## 4. `get_channels`: serial round-trips and a multi-MB payload on every
refetch

The stale gates in (1) cut refetch frequency; this cuts the cost of the
refetches that legitimately remain (boot, and focus returns after more
than 5 minutes away — previously still a multi-second stall).
`get_channels` made ~8 fully serial relay round-trips (~3.2–3.6s at
1,100+ channels), then shipped the full `ChannelInfo` list — including
every channel's member pubkeys — across IPC, where the renderer's
`JSON.parse` of the multi-MB payload froze the main thread (the ~1.25s
stall captured in the live sample).

- **Concurrent stages**: the membership chain, the open-channel
directory scan, and the hidden-DM snapshot run concurrently, as do the
member-count and last-message queries that follow. The critical path
drops from ~8 sequential round-trips to 2 phases. Filters, limits,
pagination, and merge semantics are unchanged.
- **Not-modified short-circuit**: the command now takes a
client-supplied content hash (FNV-1a 64 over the channel list,
canonicalized by id and excluding `last_message_at`) and omits the
channel list from the response when nothing else changed. Last-message
timestamps — which change on nearly every message anywhere — ship as a
small separate map that the client overlays onto its cached list with
reference preservation, so React Query's structural sharing also skips
downstream re-renders. On a typical refocus the renderer parses
kilobytes instead of megabytes. The hash is stored in the query cache
itself, tying its lifecycle to the data it describes so a community
switch can never leak a stale hash.

The E2E mock bridge speaks the new payload shape — including the
complete `last_messages` map the client treats as authoritative — and
hash canonicalization plus overlay reference-preservation are
unit-tested on both sides.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-11 19:47:19 -04:00

265 lines
7.6 KiB
TypeScript

import type {
Channel,
ChannelDetail,
ChannelMember,
ChannelMessagesPageResponse,
ChannelPageCursor,
ChannelType,
CreateChannelInput,
OpenDmInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import { invokeTauri } from "@/shared/api/tauri";
export type RawChannel = {
id: string;
name: string;
channel_type: ChannelType;
visibility: "open" | "private";
description: string;
topic: string | null;
purpose: string | null;
member_count: number;
member_pubkeys: string[];
last_message_at: string | null;
archived_at: string | null;
participants: string[];
participant_pubkeys: string[];
is_member?: boolean;
ttl_seconds: number | null;
ttl_deadline: string | null;
};
/**
* Response payload for the `get_channels` Tauri command.
*
* When `channels` is `null`, the caller's `knownHash` matched the relay
* snapshot and the expensive channel list was not re-serialized across IPC.
* `lastMessages` is always present so the caller can update sidebar
* timestamps without a full channel-list re-render.
*/
export type GetChannelsPayload = {
hash: string;
/** Full channel list, or `null` on a not-modified (hash-match) response. */
channels: Channel[] | null;
/** Map of channel id → ISO-8601 timestamp of its most recent message. */
lastMessages: Record<string, string>;
};
type RawChannelDetail = RawChannel & {
created_by: string;
created_at: string;
updated_at: string;
topic_set_by: string | null;
topic_set_at: string | null;
purpose_set_by: string | null;
purpose_set_at: string | null;
topic_required: boolean;
max_members: number | null;
nip29_group_id: string | null;
};
type RawChannelMember = {
pubkey: string;
role: ChannelMember["role"];
is_agent?: boolean;
joined_at: string;
display_name: string | null;
};
type RawChannelMembersResponse = {
members: RawChannelMember[];
next_cursor: string | null;
};
export function fromRawChannel(channel: RawChannel): Channel {
return {
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
visibility: channel.visibility,
description: channel.description,
topic: channel.topic,
purpose: channel.purpose,
memberCount: channel.member_count,
memberPubkeys: channel.member_pubkeys ?? [],
lastMessageAt: channel.last_message_at,
archivedAt: channel.archived_at,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
isMember: channel.is_member ?? true,
ttlSeconds: channel.ttl_seconds,
ttlDeadline: channel.ttl_deadline,
};
}
export function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
return {
...fromRawChannel(channel),
createdBy: channel.created_by,
createdAt: channel.created_at,
updatedAt: channel.updated_at,
topicSetBy: channel.topic_set_by,
topicSetAt: channel.topic_set_at,
purposeSetBy: channel.purpose_set_by,
purposeSetAt: channel.purpose_set_at,
topicRequired: channel.topic_required,
maxMembers: channel.max_members,
nip29GroupId: channel.nip29_group_id,
};
}
function fromRawChannelMember(member: RawChannelMember): ChannelMember {
return {
pubkey: member.pubkey,
role: member.role,
isAgent: member.is_agent ?? false,
joinedAt: member.joined_at,
displayName: member.display_name,
};
}
/**
* Fetch the channel list from the backend.
*
* Pass `knownHash` from a previous response to enable the not-modified
* short-circuit: when the relay snapshot is unchanged, `channels` in the
* returned payload will be `null` so the multi-MB list is not deserialized.
* Pass `null` to always request the full list.
*/
export async function getChannels(
knownHash: string | null,
): Promise<GetChannelsPayload> {
const raw = await invokeTauri<{
hash: string;
channels: RawChannel[] | null;
last_messages: Record<string, string>;
}>("get_channels", { knownHash });
return {
hash: raw.hash,
channels: raw.channels !== null ? raw.channels.map(fromRawChannel) : null,
lastMessages: raw.last_messages,
};
}
export async function createChannel(
input: CreateChannelInput,
): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("create_channel", input));
}
export async function ensureStarterChannels(): Promise<Channel[]> {
return (await invokeTauri<RawChannel[]>("ensure_starter_channels")).map(
fromRawChannel,
);
}
export async function openDm(input: OpenDmInput): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("open_dm", input));
}
export async function hideDm(channelId: string): Promise<void> {
await invokeTauri<void>("hide_dm", { channelId });
}
export async function getChannelDetails(
channelId: string,
): Promise<ChannelDetail> {
const detail = await invokeTauri<RawChannelDetail>("get_channel_details", {
channelId,
});
return fromRawChannelDetail(detail);
}
export async function updateChannel(
input: UpdateChannelInput,
): Promise<ChannelDetail> {
const channel = await invokeTauri<RawChannelDetail>("update_channel", {
input,
});
return fromRawChannelDetail(channel);
}
export async function setChannelTopic(
input: SetChannelTopicInput,
): Promise<void> {
await invokeTauri("set_channel_topic", input);
}
export async function setChannelPurpose(
input: SetChannelPurposeInput,
): Promise<void> {
await invokeTauri("set_channel_purpose", input);
}
export async function archiveChannel(channelId: string): Promise<void> {
await invokeTauri("archive_channel", { channelId });
}
export async function unarchiveChannel(channelId: string): Promise<void> {
await invokeTauri("unarchive_channel", { channelId });
}
export async function deleteChannel(channelId: string): Promise<void> {
await invokeTauri("delete_channel", { channelId });
}
type RawChannelMessagesPageResponse = {
events: ChannelMessagesPageResponse["events"];
next_cursor: { created_at: number; event_id: string } | null;
};
/**
* Fetch one keyset page of top-level channel history strictly older than a
* cursor, via the bridge composite `(createdAt, eventId)` cursor.
*
* The desktop timeline pages history over WS `REQ` with a bare `until`
* (`createdAt`) cursor, which cannot advance past a `createdAt` second denser
* than one page. This is the escape hatch: `beforeId` is the id of the oldest
* event already loaded at `before`, and the relay returns strictly-older rows
* (`created_at < before OR (created_at = before AND id > beforeId)`). Pass the
* returned `nextCursor` back to page further; `nextCursor` is null once a short
* page proves history is exhausted.
*/
export async function getChannelMessagesBefore(
channelId: string,
cursor: ChannelPageCursor,
limit?: number,
): Promise<ChannelMessagesPageResponse> {
const response = await invokeTauri<RawChannelMessagesPageResponse>(
"get_channel_messages_before",
{
channelId,
before: cursor.createdAt,
beforeId: cursor.eventId,
limit: limit ?? null,
},
);
return {
events: response.events,
nextCursor: response.next_cursor
? {
createdAt: response.next_cursor.created_at,
eventId: response.next_cursor.event_id,
}
: null,
};
}
export async function getChannelMembers(
channelId: string,
): Promise<ChannelMember[]> {
const response = await invokeTauri<RawChannelMembersResponse>(
"get_channel_members",
{ channelId },
);
return response.members.map(fromRawChannelMember);
}
export async function joinChannel(channelId: string): Promise<void> {
await invokeTauri<void>("join_channel", { channelId });
}