From 1a994f75323f5dff899c623f567f9bd70d543af0 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 28 Oct 2026 09:52:00 -0700 Subject: [PATCH] Keep the proxy app fast with many flows The app held every flow in memory and redid all of its work for each new one: the sidebar mounted every domain and path node (its tree scrolled inside a container that never bounded it), every tree row looked up its atoms by scanning every atom the family had made, and the layout, detail pane and list rows re-rendered per flow. At 3,000 flows each new one cost about 90 ms of main thread, more than the traffic left time for. The app now holds a window of the newest 2,000 flows, applies new ones in batches, and loads older ones in pages from the database on scrolling to the top, through a new list_flows RPC that pages in SQL. The database keeps every flow. Also adds clear_flows: an RPC, POST /flows/clear on the control API, and a Clear button in the header that Cmd+K opens, confirming first. Co-Authored-By: Claude Opus 5.5 --- .../components/ClearFlowsButton.tsx | 49 +++++ apps/yaak-proxy/components/FlowDetail.tsx | 12 +- apps/yaak-proxy/components/FlowList.tsx | 84 ++++++--- apps/yaak-proxy/components/ProxyLayout.tsx | 24 +-- apps/yaak-proxy/components/Sidebar.tsx | 176 ++++++++---------- apps/yaak-proxy/lib/atoms.ts | 9 +- apps/yaak-proxy/lib/flowWindow.test.ts | 131 +++++++++++++ apps/yaak-proxy/lib/flowWindow.ts | 100 ++++++++++ apps/yaak-proxy/lib/store.ts | 124 +++++++++++- apps/yaak-proxy/main.tsx | 13 +- apps/yaak-proxy/package.json | 1 - .../yaak-proxy-lib/bindings/gen_rpc.ts | 53 +++++- crates-proxy/yaak-proxy-lib/src/actions.rs | 8 + crates-proxy/yaak-proxy-lib/src/control.rs | 10 + crates-proxy/yaak-proxy-lib/src/flows.rs | 169 ++++++++++++++--- crates-proxy/yaak-proxy-lib/src/lib.rs | 47 +++-- crates-proxy/yaak-proxy-lib/tests/control.rs | 10 + package-lock.json | 1 - packages/ui/src/components/tree/atoms.ts | 50 +++-- 19 files changed, 855 insertions(+), 216 deletions(-) create mode 100644 apps/yaak-proxy/components/ClearFlowsButton.tsx create mode 100644 apps/yaak-proxy/lib/flowWindow.test.ts create mode 100644 apps/yaak-proxy/lib/flowWindow.ts diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx new file mode 100644 index 000000000..eb7cbdf25 --- /dev/null +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -0,0 +1,49 @@ +import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { flowTotalAtom } from "../lib/store"; + +/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */ +export function ClearFlowsButton() { + const total = useAtomValue(flowTotalAtom); + const clear = useRpcMutation("clear_flows"); + + const items: DropdownItem[] = [ + { + type: "content", + label: ( +
+
+ {total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`} +
+
+ {clear.error != null ? ( + {clear.error.message} + ) : ( + "They are deleted from disk with their bodies, for good." + )} +
+
+ ), + }, + { type: "separator" }, + { + label: "Clear", + icon: "trash", + color: "danger", + onSelect: () => clear.mutate({}), + }, + ]; + + return ( + + + + ); +} diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index 061166cac..bdddc84b8 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -17,21 +17,15 @@ import { Tabs, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { atom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import type { CSSProperties } from "react"; import { useMemo, useState } from "react"; -import { selectedFlowIdAtom } from "../lib/atoms"; +import { selectedFlowAtom } from "../lib/atoms"; import { findHeader, mimeEssence } from "../lib/body"; import { statusReason } from "../lib/statusReason"; -import { flowsAtom } from "../lib/store"; import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer"; import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders"; -const selectedFlowAtom = atom((get) => { - const id = get(selectedFlowIdAtom); - return id == null ? null : (get(flowsAtom).find((f) => f.id === id) ?? null); -}); - const TAB_RESPONSE = "response"; const TAB_REQUEST = "request"; const TAB_HEADERS = "headers"; @@ -60,6 +54,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB Stop the proxy + Clear flows + ) : ( diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 10283af85..f73f3d074 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -3,34 +3,48 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; import { formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtom, useAtomValue } from "jotai"; -import type { CSSProperties, KeyboardEvent } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import type { CSSProperties, KeyboardEvent, WheelEvent } from "react"; import { memo, useCallback, useLayoutEffect, useRef, useState } from "react"; -import { selectedFlowIdAtom } from "../lib/atoms"; +import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { shortContentType } from "../lib/body"; +import { compareFlows } from "../lib/flowWindow"; import { countNewFlows } from "../lib/follow"; -import { flowsAtom } from "../lib/store"; +import { followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom } from "../lib/store"; +import { filteredExchangesAtom } from "./Sidebar"; const ROW_HEIGHT = 24; const BOTTOM_PADDING = 6; +/** Rows from the top at which older flows start loading */ +const LOAD_OLDER_ROWS = 20; interface Props { - flows: Flow[]; sideBySide: boolean; style?: CSSProperties; className?: string; } -/** Follows new traffic until a flow is selected or the list is scrolled, then holds */ -export function FlowList({ flows, sideBySide, style, className }: Props) { - const [selectedId, setSelectedId] = useAtom(selectedFlowIdAtom); +/** + * Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to + * the top loads older flows from the database. + */ +export function FlowList({ sideBySide, style, className }: Props) { + const flows = useAtomValue(filteredExchangesAtom); + const selectedId = useAtomValue(selectedFlowIdAtom); + const setSelectedFlow = useSetAtom(selectedFlowAtom); + const loadOlder = useSetAtom(loadOlderFlowsAtom); + const oldestFlow = useAtomValue(oldestFlowAtom); const scrollRef = useRef(null); const ignoreScroll = useRef(false); - const [following, setFollowing] = useState(true); + const [following, setFollowing] = useAtom(followAtom); const [heldAt, setHeldAt] = useState(null); // The newest flow across all traffic, not just this list, so a filter change keeps the hold - const allFlows = useAtomValue(flowsAtom); - const newestStartedAt = allFlows[allFlows.length - 1]?.startedAt ?? null; + const newestStartedAt = useAtomValue(newestStartedAtAtom); + // Read by the callbacks below, which stay the same across new traffic so rows don't re-render + const latest = useRef({ flows, following, newestStartedAt }); + useLayoutEffect(() => { + latest.current = { flows, following, newestStartedAt }; + }); const virtualizer = useVirtualizer({ count: flows.length, @@ -52,20 +66,37 @@ export function FlowList({ flows, sideBySide, style, className }: Props) { }, []); const hold = useCallback(() => { - if (!following) return; + if (!latest.current.following) return; setFollowing(false); - setHeldAt(newestStartedAt); - }, [following, newestStartedAt]); + setHeldAt(latest.current.newestStartedAt); + }, [setFollowing]); const follow = useCallback(() => { setFollowing(true); setHeldAt(null); setScrollTop(Number.MAX_SAFE_INTEGER); - }, [setScrollTop]); + }, [setFollowing, setScrollTop]); useLayoutEffect(() => { if (following) setScrollTop(Number.MAX_SAFE_INTEGER); - }, [following, flows.length, setScrollTop]); + }, [following, flows, setScrollTop]); + + // Older flows that loaded in above push the rows down; scroll by as much, so they stay put. + const anchor = useRef(null); + useLayoutEffect(() => { + const previous = anchor.current; + anchor.current = oldestFlow; + if (following || previous == null || oldestFlow == null) return; + if (compareFlows(oldestFlow, previous) >= 0) return; + const added = flows.findIndex((f) => compareFlows(f, previous) >= 0); + const el = scrollRef.current; + if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT); + }, [oldestFlow, flows, following, setScrollTop]); + + const loadOlderHeld = useCallback(() => { + hold(); + void loadOlder(); + }, [hold, loadOlder]); const handleScroll = useCallback(() => { if (ignoreScroll.current) { @@ -80,14 +111,23 @@ export function FlowList({ flows, sideBySide, style, className }: Props) { setHeldAt(null); } else { hold(); + if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadOlder(); } - }, [hold]); + }, [hold, loadOlder, setFollowing]); + + // A list too short to scroll still loads older flows on scrolling up. + const handleWheel = useCallback( + (e: WheelEvent) => { + if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) loadOlderHeld(); + }, + [loadOlderHeld], + ); const select = useCallback( (index: number) => { - const flow = flows[index]; + const flow = latest.current.flows[index]; if (flow == null) return; - setSelectedId(flow.id); + setSelectedFlow(flow); hold(); const el = scrollRef.current; if (el == null) return; @@ -97,7 +137,7 @@ export function FlowList({ flows, sideBySide, style, className }: Props) { setScrollTop(top + ROW_HEIGHT - el.clientHeight); } }, - [flows, hold, setSelectedId, setScrollTop], + [hold, setSelectedFlow, setScrollTop], ); const handleKeyDown = useCallback( @@ -107,9 +147,10 @@ export function FlowList({ flows, sideBySide, style, className }: Props) { e.preventDefault(); const index = flows.findIndex((f) => f.id === selectedId); if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1); + else if (index === 0 && e.key === "ArrowUp") loadOlderHeld(); else select(e.key === "ArrowDown" ? index + 1 : index - 1); }, - [flows, select, selectedId], + [flows, loadOlderHeld, select, selectedId], ); const newCount = following ? 0 : countNewFlows(flows, heldAt); @@ -146,6 +187,7 @@ export function FlowList({ flows, sideBySide, style, className }: Props) { aria-label="Flows" tabIndex={0} onScroll={handleScroll} + onWheel={handleWheel} onKeyDown={handleKeyDown} className="group/flows h-full overflow-y-auto px-1.5 outline-hidden" > diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index cfb562e84..bee892451 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -5,14 +5,14 @@ import { useState } from "react"; import { useLocalStorage } from "react-use"; import { flowLayoutAtom } from "../lib/atoms"; import { getOsType } from "../lib/tauri"; +import { ClearFlowsButton } from "./ClearFlowsButton"; import { FlowDetail } from "./FlowDetail"; import { FlowList } from "./FlowList"; import { ProxyStatus } from "./ProxyStatus"; -import { filteredExchangesAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; +import { filteredCountAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; export function ProxyLayout() { const os = getOsType(); - const exchanges = useAtomValue(filteredExchangesAtom); const title = useAtomValue(selectedSidebarLabelAtom); const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom); const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250); @@ -61,12 +61,11 @@ export function ProxyLayout() { className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0" > {title} - - {exchanges.length === 1 ? "1 flow" : `${exchanges.length} flows`} - +
+ ( - + )} secondSlot={({ style, orientation }) => ( @@ -142,3 +136,11 @@ export function ProxyLayout() {
); } + +/** Its own component, so new traffic re-renders the count rather than the whole layout */ +function FlowCount() { + const count = useAtomValue(filteredCountAtom); + return ( + {count === 1 ? "1 flow" : `${count} flows`} + ); +} diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index fdf391d41..6117b65cf 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -4,13 +4,14 @@ import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai-family"; import { useCallback } from "react"; -import { flowsAtom } from "../lib/store"; +import { flowsAtom, flowTotalAtom } from "../lib/store"; /** A node in the sidebar tree — either a domain or a path segment. */ export type SidebarItem = { id: string; label: string; - exchangeIds: string[]; + /** Flows held under this node; for the Domains folder, every flow in the database */ + count: number; }; const collapsedAtom = atomFamily((_treeId: string) => atom>({})); @@ -19,37 +20,32 @@ export const SIDEBAR_TREE_ID = "proxy-sidebar"; const sidebarTreeAtom = atom>((get) => { const exchanges = get(flowsAtom); - return buildTree(exchanges); + return buildTree(exchanges, get(flowTotalAtom)); +}); + +/** The sidebar nodes selected, or null when the list shows all traffic */ +const selectedNodeIdsAtom = atom((get) => { + const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID)); + // Nothing selected, or a node holding everything → show all + if (selectedIds.length === 0 || selectedIds.includes("root") || selectedIds.includes("domains")) { + return null; + } + return new Set(selectedIds); }); /** Exchanges filtered by the currently selected sidebar node(s). */ export const filteredExchangesAtom = atom((get) => { const exchanges = get(flowsAtom); - const tree = get(sidebarTreeAtom); - const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID)); - - // Nothing selected or root selected → show all - if (selectedIds.length === 0 || selectedIds.includes("root")) { - return exchanges; - } - - // Collect exchange IDs from all selected nodes - const allowedIds = new Set(); - const nodeMap = new Map(); - collectNodes(tree, nodeMap); - - for (const selectedId of selectedIds) { - const node = nodeMap.get(selectedId); - if (node) { - for (const id of node.exchangeIds) { - allowedIds.add(id); - } - } - } - - return exchanges.filter((ex) => allowedIds.has(ex.id)); + const selected = get(selectedNodeIdsAtom); + if (selected == null) return exchanges; + return exchanges.filter((ex) => nodeIdsOf(ex).some((id) => selected.has(id))); }); +/** Flows in the database for all traffic, or those held under the selected nodes */ +export const filteredCountAtom = atom((get) => + get(selectedNodeIdsAtom) == null ? get(flowTotalAtom) : get(filteredExchangesAtom).length, +); + /** The selected sidebar node's name, for the header */ export const selectedSidebarLabelAtom = atom((get) => { const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0]; @@ -58,13 +54,37 @@ export const selectedSidebarLabelAtom = atom((get) => { return "All traffic"; }); -function collectNodes(node: TreeNode, map: Map) { - map.set(node.item.id, node.item); - for (const child of node.children ?? []) { - collectNodes(child, map); +const nodeIdsByFlow = new WeakMap(); + +/** + * The ids of the nodes a flow sits under, its domain first and then each path segment's, parsed + * once per flow rather than on every change to the list. + */ +function nodeIdsOf(ex: Flow): string[] { + let ids = nodeIdsByFlow.get(ex); + if (ids != null) return ids; + let hostname: string; + let segments: string[]; + try { + const url = new URL(ex.url); + hostname = url.host; + segments = url.pathname.split("/").filter(Boolean); + } catch { + hostname = ex.url; + segments = []; } + ids = [`domain:${hostname}`]; + let path = `path:${hostname}`; + for (const seg of segments) { + path += `/${seg}`; + ids.push(path); + } + nodeIdsByFlow.set(ex, ids); + return ids; } +const collator = new Intl.Collator(); + /** * Build a domain → path-segment trie from a flat list of exchanges. * @@ -80,8 +100,8 @@ function collectNodes(node: TreeNode, map: Map * /123 * /orders */ -function buildTree(exchanges: Flow[]): TreeNode { - const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] }; +function buildTree(exchanges: Flow[], total: number): TreeNode { + const root: SidebarItem = { id: "root", label: "All Traffic", count: 0 }; const rootNode: TreeNode = { item: root, parent: null, @@ -94,54 +114,26 @@ function buildTree(exchanges: Flow[]): TreeNode { type TrieNode = { id: string; label: string; - exchangeIds: string[]; + count: number; children: Map; }; const domainMap = new Map(); for (const ex of exchanges) { - let hostname: string; - let segments: string[]; - try { - const url = new URL(ex.url); - hostname = url.host; - segments = url.pathname.split("/").filter(Boolean); - } catch { - hostname = ex.url; - segments = []; - } - - // Get or create domain node - let domainNode = domainMap.get(hostname); - if (!domainNode) { - domainNode = { - id: `domain:${hostname}`, - label: hostname, - exchangeIds: [], - children: new Map(), - }; - domainMap.set(hostname, domainNode); - } - domainNode.exchangeIds.push(ex.id); - - // Walk path segments - let current = domainNode; - const pathSoFar: string[] = []; - for (const seg of segments) { - pathSoFar.push(seg); - let child = current.children.get(seg); - if (!child) { - child = { - id: `path:${hostname}/${pathSoFar.join("/")}`, - label: `/${seg}`, - exchangeIds: [], - children: new Map(), - }; - current.children.set(seg, child); + const ids = nodeIdsOf(ex); + let current: TrieNode | undefined; + for (const id of ids) { + const siblings = current == null ? domainMap : current.children; + let node = siblings.get(id); + if (node == null) { + // The domain, or the path's last segment + const label = current == null ? id.slice("domain:".length) : id.slice(id.lastIndexOf("/")); + node = { id, label, count: 0, children: new Map() }; + siblings.set(id, node); } - child.exchangeIds.push(ex.id); - current = child; + node.count++; + current = node; } } @@ -155,7 +147,7 @@ function buildTree(exchanges: Flow[]): TreeNode { item: { id: trie.id, label: trie.label, - exchangeIds: trie.exchangeIds, + count: trie.count, }, parent, depth, @@ -163,7 +155,7 @@ function buildTree(exchanges: Flow[]): TreeNode { draggable: false, }; const sortedChildren = [...trie.children.values()].sort((a, b) => - a.label.localeCompare(b.label), + collator.compare(a.label, b.label), ); for (const child of sortedChildren) { node.children?.push(toTreeNode(child, node, depth + 1)); @@ -172,16 +164,15 @@ function buildTree(exchanges: Flow[]): TreeNode { } // Add a "Domains" folder between root and domain nodes - const allExchangeIds = exchanges.map((ex) => ex.id); const domainsFolder: TreeNode = { - item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds }, + item: { id: "domains", label: "Domains", count: total }, parent: rootNode, depth: 1, children: [], draggable: false, }; - const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label)); + const sortedDomains = [...domainMap.values()].sort((a, b) => collator.compare(a.label, b.label)); for (const domain of sortedDomains) { domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2)); } @@ -192,7 +183,7 @@ function buildTree(exchanges: Flow[]): TreeNode { } function ItemInner({ item }: { item: SidebarItem }) { - const count = item.exchangeIds.length; + const count = item.count; return (
{item.label} @@ -205,23 +196,20 @@ export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); const treeId = SIDEBAR_TREE_ID; - const getItemKey = useCallback( - (item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`, - [], - ); + const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []); return ( -