diff --git a/docs/development.md b/docs/development.md index 6ae0fea6a..03d8d5f5d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,6 +43,70 @@ In any worktree-style or portless setup, never assume default ports. `http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP. Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +### React render profiling + +The app has a gated React render profiler in +`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want +to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When +the query param is absent, `RenderProfile` returns children directly and records +nothing. + +Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call +`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the +interaction you want to measure. If a memo comparator or subscription boundary +needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling; +reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`. + +Use this workflow for any render investigation: + +1. Add stable `RenderProfile` boundaries around the suspected root and expensive + children. Keep IDs specific enough to compare before and after. +2. Reproduce against real app state, not toy fixtures, whenever practical. +3. Record an idle baseline first. If idle is noisy, fix or account for that + before optimizing the interaction. +4. Warm up the route, reset profiler samples, run the exact interaction, then + compare `actualDuration`, render counts, and per-commit samples. +5. When a memo boundary still renders, record reasons before changing code. Do + not guess from object identity alone. +6. Keep changes that move the measured profile. Remove probes or memo wrappers + that do not move the number. + +What this caught during the workspace tab investigation: + +- A large apparent workspace cost was real interaction work, not daemon noise; + the idle baseline stayed near zero. +- The expensive stream rerender was mostly prop identity churn from pane context + callbacks and capability objects, not new stream data. +- Stabilizing provider actions at the pane boundary helped because every mounted + panel consumes that context. +- Comparing value-shaped capability flags beat preserving object identity through + unrelated stores. +- Some plausible fixes did not pay off: memoizing the tab row and composer draft + object barely moved the profile, so they were removed. + +Existing scenario script: workspace agent/terminal tab switching. Start Expo on +web, keep a daemon available, then run: + +```bash +PASEO_PROFILE_SERVER_ID= \ +PASEO_PROFILE_WORKSPACE_ID= \ +PASEO_PROFILE_AGENT_ID= \ + npm run profile:workspace-tabs --workspace=@getpaseo/app +``` + +This script opens the app with `?renderProfile=1`, creates a temporary terminal +tab, switches between a real agent and that terminal, prints aggregated React +Profiler timings, then removes the temporary terminal. It is an example of the +workflow above, not the only way to use the profiler. Useful knobs: + +```bash +PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL +PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs +PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click +PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching +PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples +``` + ### Desktop macOS compositor watchdog macOS display sleep can leave Chromium's GPU-process display link — the vsync diff --git a/packages/app/package.json b/packages/app/package.json index 96b4f57f6..d42e3f011 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,6 +26,7 @@ "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", + "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs" }, diff --git a/packages/app/scripts/profile-workspace-tabs.mjs b/packages/app/scripts/profile-workspace-tabs.mjs new file mode 100644 index 000000000..c5297634d --- /dev/null +++ b/packages/app/scripts/profile-workspace-tabs.mjs @@ -0,0 +1,172 @@ +import { execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010"; +const serverId = requiredEnv("PASEO_PROFILE_SERVER_ID"); +const workspaceId = requiredEnv("PASEO_PROFILE_WORKSPACE_ID"); +const agentId = requiredEnv("PASEO_PROFILE_AGENT_ID"); +const switchCount = Number(process.env.PASEO_PROFILE_SWITCH_COUNT ?? 6); +const switchWaitMs = Number(process.env.PASEO_PROFILE_SWITCH_WAIT_MS ?? 250); +const idleWaitMs = Number(process.env.PASEO_PROFILE_IDLE_WAIT_MS ?? 0); +const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1"; + +function requiredEnv(name) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function createTerminal() { + const output = execFileSync( + "npm", + [ + "run", + "cli", + "--", + "terminal", + "create", + "--cwd", + workspaceId, + "--name", + "Tab Switch Profile", + "--json", + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + const match = output.match(/\{[\s\S]*\}/); + if (!match) { + throw new Error(`Could not parse terminal create output: ${output}`); + } + return JSON.parse(match[0]).id; +} + +function killTerminal(terminalId) { + execFileSync("npm", ["run", "cli", "--", "terminal", "kill", terminalId], { + cwd: repoRoot, + encoding: "utf8", + }); +} + +const createdTerminalId = process.env.PASEO_PROFILE_TERMINAL_ID ? null : createTerminal(); +const terminalId = process.env.PASEO_PROFILE_TERMINAL_ID ?? createdTerminalId; +const workspaceSegment = `b64_${Buffer.from(workspaceId, "utf8").toString("base64url")}`; +const workspaceUrl = `${baseUrl}/h/${serverId}/workspace/${workspaceSegment}`; + +function tabTestId(kind, id) { + return `workspace-tab-${kind}_${id}`; +} + +function summarize(samples) { + const byId = new Map(); + for (const sample of samples) { + const current = byId.get(sample.id) ?? { + renders: 0, + actualDuration: 0, + baseDuration: 0, + }; + current.renders += 1; + current.actualDuration += sample.actualDuration; + current.baseDuration += sample.baseDuration; + byId.set(sample.id, current); + } + return [...byId.entries()] + .map(([id, value]) => Object.assign({ id }, value)) + .sort((left, right) => right.actualDuration - left.actualDuration); +} + +function summarizeCommits(samples) { + const byCommit = new Map(); + for (const sample of samples) { + const key = String(sample.commitTime); + const current = byCommit.get(key) ?? { + commitTime: sample.commitTime, + totalActualDuration: 0, + samples: [], + }; + current.totalActualDuration += sample.actualDuration; + current.samples.push({ + id: sample.id, + actualDuration: sample.actualDuration, + baseDuration: sample.baseDuration, + }); + byCommit.set(key, current); + } + + return [...byCommit.values()].sort((left, right) => left.commitTime - right.commitTime); +} + +async function openIntent(page, value) { + const url = `${workspaceUrl}?renderProfile=1&open=${encodeURIComponent(value)}`; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.getByTestId("workspace-tabs-row").waitFor({ timeout: 60_000 }); +} + +async function clickTab(page, testId) { + await page.getByTestId(testId).click(); + await page.waitForTimeout(switchWaitMs); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + try { + await openIntent(page, `agent:${agentId}`); + await openIntent(page, `terminal:${terminalId}`); + + const agentTab = tabTestId("agent", agentId); + const terminalTab = tabTestId("terminal", terminalId); + await page.getByTestId(agentTab).waitFor({ timeout: 60_000 }); + await page.getByTestId(terminalTab).waitFor({ timeout: 60_000 }); + + await clickTab(page, agentTab); + await clickTab(page, terminalTab); + await page.waitForTimeout(500); + await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.()); + + if (idleWaitMs > 0) { + await page.waitForTimeout(idleWaitMs); + } + + for (let index = 0; index < switchCount; index += 1) { + await clickTab(page, agentTab); + await clickTab(page, terminalTab); + } + + const samples = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__ ?? []); + const reasons = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}); + console.log( + JSON.stringify( + { + appUrl: baseUrl, + serverId, + workspaceId, + agentId, + terminalId, + switchCount, + switchWaitMs, + idleWaitMs, + samples: samples.length, + summary: summarize(samples), + reasons, + commits: dumpCommits ? summarizeCommits(samples) : undefined, + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + if (createdTerminalId) { + killTerminal(createdTerminalId); + } + } +} + +await main(); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 4e77405cc..9dbe8e305 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { + AgentCapabilityFlags, AgentPermissionAction, AgentPermissionResponse, } from "@getpaseo/protocol/agent-types"; @@ -77,6 +78,7 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; +import { recordRenderProfileReasons } from "@/utils/render-profiler"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; @@ -214,6 +216,18 @@ export interface AgentStreamViewProps { onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; } +const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ + "supportsStreaming", + "supportsSessionPersistence", + "supportsDynamicModes", + "supportsMcpServers", + "supportsReasoningStream", + "supportsToolInvocations", + "supportsRewindConversation", + "supportsRewindFiles", + "supportsRewindBoth", +]; + const EMPTY_STREAM_HEAD: StreamItem[] = []; const AgentStreamViewComponent = forwardRef( @@ -745,7 +759,68 @@ const AgentStreamViewComponent = forwardRef left?.[key] === right?.[key]); +} + +function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { + const reasons: string[] = []; + if (left.serverId !== right.serverId) reasons.push("agent.serverId"); + if (left.id !== right.id) reasons.push("agent.id"); + if (left.status !== right.status) reasons.push("agent.status"); + if (left.cwd !== right.cwd) reasons.push("agent.cwd"); + if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) { + reasons.push("agent.capabilities"); + } + if (left.lastError !== right.lastError) reasons.push("agent.lastError"); + if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) { + reasons.push("agent.projectPlacement.checkout.cwd"); + } + if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) { + reasons.push("agent.projectPlacement.checkout.isGit"); + } + return reasons; +} + +function bottomAnchorRouteRequestsEqual( + left: BottomAnchorRouteRequest | null | undefined, + right: BottomAnchorRouteRequest | null | undefined, +): boolean { + return ( + left?.agentId === right?.agentId && + left?.reason === right?.reason && + left?.requestKey === right?.requestKey + ); +} + +function agentStreamViewPropsEqual( + left: AgentStreamViewProps, + right: AgentStreamViewProps, +): boolean { + const reasons: string[] = []; + if (left.agentId !== right.agentId) reasons.push("agentId"); + if (left.serverId !== right.serverId) reasons.push("serverId"); + reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent)); + if (left.streamItems !== right.streamItems) reasons.push("streamItems"); + if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); + if ( + !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) + ) { + reasons.push("routeBottomAnchorRequest"); + } + if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) { + reasons.push("isAuthoritativeHistoryReady"); + } + if (left.toast !== right.toast) reasons.push("toast"); + if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); + recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons); + return reasons.length === 0; +} + +export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual); AgentStreamView.displayName = "AgentStreamView"; interface ToolCallSlotProps extends Omit< diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index e9f673c67..ea1fe107d 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, type ReactElement } from "react"; +import { memo, useCallback, useMemo, useRef, type ReactElement } from "react"; import { ScrollView, View } from "react-native"; import { DndContext, @@ -43,7 +43,7 @@ interface SortableItemProps { useDragHandle: boolean; } -function SortableItem({ +function SortableItemInner({ id, item, index, @@ -118,6 +118,8 @@ function SortableItem({ ); } +const SortableItem = memo(SortableItemInner) as typeof SortableItemInner; + export function DraggableList({ data, keyExtractor, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4c9e2299a..a1c7e6d05 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -17,6 +17,7 @@ import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { + memo, useCallback, useMemo, useState, @@ -27,7 +28,11 @@ import { type Ref, } from "react"; import { router, usePathname, type Href } from "expo-router"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + navigateToWorkspace, + useActiveWorkspaceSelection, + type ActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; @@ -101,7 +106,6 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; -import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; @@ -173,6 +177,52 @@ function getPrIconUniMapping(state: PrHint["state"]) { } } +function useStableProjectIconData( + data: (string | null)[], + signature: string, +): readonly (string | null)[] { + const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null); + if (stableRef.current?.signature !== signature) { + stableRef.current = { signature, data }; + } + return stableRef.current.data; +} + +function isWorkspaceSelected(input: { + selection: ActiveWorkspaceSelection | null; + serverId: string | null; + workspaceId: string; + enabled: boolean; +}): boolean { + return ( + input.enabled && + input.selection?.serverId === input.serverId && + input.selection.workspaceId === input.workspaceId + ); +} + +function isProjectSelectedByRoute(input: { + selection: ActiveWorkspaceSelection | null; + project: SidebarProjectEntry; + serverId: string | null; + enabled: boolean; +}): boolean { + return ( + input.enabled && + input.selection?.serverId === input.serverId && + input.project.workspaces.some( + (workspace) => workspace.workspaceId === input.selection?.workspaceId, + ) + ); +} + +function selectionForSelectedWorkspace( + selected: boolean, + workspace: SidebarWorkspaceEntry, +): ActiveWorkspaceSelection | null { + return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null; +} + interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; serverId: string | null; @@ -1493,7 +1543,6 @@ function WorkspaceRowWithMenu({ isCreating?: boolean; }) { const toast = useToast(); - const activeWorkspaceSelection = useActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); @@ -1516,9 +1565,9 @@ function WorkspaceRowWithMenu({ redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, - activeWorkspaceSelection, + activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), }); - }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); + }, [selected, workspace]); const archiveWorktreeAfterConfirmation = useCallback(async () => { if (isArchiving) { @@ -1755,15 +1804,14 @@ function NonGitProjectRowWithMenuContent({ }) { const toast = useToast(); const contextMenu = useContextMenu(); - const activeWorkspaceSelection = useActiveWorkspaceSelection(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, - activeWorkspaceSelection, + activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), }); - }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); + }, [selected, workspace]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { @@ -1883,6 +1931,7 @@ function FlattenedProjectRow({ onRemoveProject, removeProjectStatus, selectionEnabled, + activeWorkspaceSelection, }: { project: SidebarProjectEntry; displayName: string; @@ -1901,13 +1950,15 @@ function FlattenedProjectRow({ onRemoveProject?: () => void; removeProjectStatus?: "idle" | "pending"; selectionEnabled: boolean; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; }) { const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); - const activeWorkspaceSelection = useActiveWorkspaceSelection(); - const selected = - selectionEnabled && - activeWorkspaceSelection?.serverId === serverId && - activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId; + const selected = isWorkspaceSelected({ + selection: activeWorkspaceSelection, + serverId, + workspaceId: rowModel.workspace.workspaceId, + enabled: selectionEnabled, + }); if (!workspace) { return null; @@ -1965,7 +2016,7 @@ interface WorkspaceRowItemProps { isCreating?: boolean; selectionEnabled: boolean; serverId: string | null; - currentPathname: string | null; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; onWorkspacePress?: () => void; drag?: () => void; isDragging?: boolean; @@ -1980,7 +2031,7 @@ function WorkspaceRowItem({ isCreating = false, selectionEnabled, serverId, - currentPathname, + activeWorkspaceSelection, onWorkspacePress, drag, isDragging = false, @@ -1991,8 +2042,8 @@ function WorkspaceRowItem({ return; } onWorkspacePress?.(); - navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname }); - }, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]); + navigateToWorkspace(serverId, workspace.workspaceId); + }, [serverId, onWorkspacePress, workspace.workspaceId]); return ( void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorktreeCreated?: (workspaceId: string) => void; - currentPathname: string | null; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; creatingWorkspaceIds: ReadonlySet; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; }) { const rowModel = useMemo( () => @@ -2110,15 +2194,12 @@ function ProjectBlock({ [collapsed, project], ); - const projectWorkspaceIds = useMemo( - () => project.workspaces.map((workspace) => workspace.workspaceId), - [project.workspaces], - ); - const activeWorkspaceSelection = useActiveWorkspaceSelection(); - const isProjectActive = - selectionEnabled && - activeWorkspaceSelection?.serverId === serverId && - projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId); + const active = isProjectSelectedByRoute({ + selection: activeWorkspaceSelection, + serverId, + project, + enabled: selectionEnabled, + }); const renderWorkspaceRow = useCallback( ( @@ -2130,7 +2211,7 @@ function ProjectBlock({ }, ) => { return ( - { onToggleCollapsed(project.projectKey); @@ -2254,10 +2333,11 @@ function ProjectBlock({ drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} - isProjectActive={isProjectActive} + isProjectActive={active} onRemoveProject={handleRemoveProject} removeProjectStatus={isRemovingProject ? "pending" : "idle"} selectionEnabled={selectionEnabled} + activeWorkspaceSelection={activeWorkspaceSelection} /> ) : ( <> @@ -2271,7 +2351,7 @@ function ProjectBlock({ onPress={handleToggleCollapsed} serverId={serverId} canCreateWorktree={rowModel.trailingAction === "new_worktree"} - isProjectActive={isProjectActive} + isProjectActive={active} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} drag={drag} @@ -2303,6 +2383,46 @@ function ProjectBlock({ ); } +type ProjectBlockProps = Parameters[0]; + +function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { + const previousActive = isProjectSelectedByRoute({ + selection: previous.activeWorkspaceSelection, + project: previous.project, + serverId: previous.serverId, + enabled: previous.selectionEnabled, + }); + const nextActive = isProjectSelectedByRoute({ + selection: next.activeWorkspaceSelection, + project: next.project, + serverId: next.serverId, + enabled: next.selectionEnabled, + }); + return ( + previous.project === next.project && + previous.collapsed === next.collapsed && + previous.displayName === next.displayName && + previous.iconDataUri === next.iconDataUri && + previous.serverId === next.serverId && + previous.selectionEnabled === next.selectionEnabled && + previous.showShortcutBadges === next.showShortcutBadges && + previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + previous.parentGestureRef === next.parentGestureRef && + previous.onToggleCollapsed === next.onToggleCollapsed && + previous.onWorkspacePress === next.onWorkspacePress && + previous.onWorkspaceReorder === next.onWorkspaceReorder && + previous.onWorktreeCreated === next.onWorktreeCreated && + previous.drag === next.drag && + previous.isDragging === next.isDragging && + previous.dragHandleProps === next.dragHandleProps && + previous.useNestable === next.useNestable && + previous.creatingWorkspaceIds === next.creatingWorkspaceIds && + previousActive === nextActive + ); +} + +const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); + export function SidebarWorkspaceList({ projects, serverId, @@ -2333,6 +2453,7 @@ export function SidebarWorkspaceList({ [pathname], ); const selectionEnabled = isWorkspaceRoute; + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const projectIconRequests = useMemo(() => { if (!serverId) { @@ -2374,6 +2495,12 @@ export function SidebarWorkspaceList({ })), }); + const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000"); + const projectIconData = useStableProjectIconData( + projectIconQueries.map((query) => query.data ?? null), + projectIconSignature, + ); + const projectIconByProjectKey = useMemo(() => { const iconByServerAndCwd = new Map(); for (let index = 0; index < projectIconRequests.length; index += 1) { @@ -2381,10 +2508,7 @@ export function SidebarWorkspaceList({ if (!request) { continue; } - iconByServerAndCwd.set( - `${request.serverId}:${request.cwd}`, - projectIconQueries[index]?.data ?? null, - ); + iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null); } const byProject = new Map(); @@ -2398,7 +2522,7 @@ export function SidebarWorkspaceList({ } return byProject; - }, [projectIconQueries, projectIconRequests, projects, serverId]); + }, [projectIconData, projectIconRequests, projects, serverId]); useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; @@ -2532,7 +2656,7 @@ export function SidebarWorkspaceList({ const renderProject = useCallback( ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => { return ( - ); }, [ collapsedProjectKeys, + activeWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, - pathname, projectIconByProjectKey, selectionEnabled, serverId, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 9657c09e6..fde510ee8 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -67,6 +67,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { RenderProfile } from "@/utils/render-profiler"; import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { isNative } from "@/constants/platform"; @@ -78,7 +79,6 @@ interface SplitContainerProps { isWorkspaceFocused: boolean; uiTabs: WorkspaceTab[]; hoveredCloseTabKey: string | null; - setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; closingTabIds: Set; onNavigateTab: (tabId: string) => void; @@ -215,14 +215,16 @@ const MountedTabSlot = memo(function MountedTabSlot({ }, [onFocusPane, paneId]); return ( - - - + + + + + ); }); @@ -355,7 +357,6 @@ export function SplitContainer({ isWorkspaceFocused, uiTabs, hoveredCloseTabKey, - setHoveredTabKey, setHoveredCloseTabKey, closingTabIds, onNavigateTab, @@ -553,63 +554,64 @@ export function SplitContainer({ ); return ( - - - - {activeDragTabId ? ( - - ) : null} - - + + + + + {activeDragTabId ? ( + + ) : null} + + + ); } @@ -711,7 +713,6 @@ function SplitNodeView({ normalizedWorkspaceId, isWorkspaceFocused, hoveredCloseTabKey, - setHoveredTabKey, setHoveredCloseTabKey, closingTabIds, onNavigateTab, @@ -764,7 +765,6 @@ function SplitNodeView({ normalizedWorkspaceId={normalizedWorkspaceId} isWorkspaceFocused={isWorkspaceFocused} hoveredCloseTabKey={hoveredCloseTabKey} - setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} closingTabIds={closingTabIds} onNavigateTab={onNavigateTab} @@ -810,7 +810,6 @@ function SplitNodeView({ normalizedWorkspaceId={normalizedWorkspaceId} isWorkspaceFocused={isWorkspaceFocused} hoveredCloseTabKey={hoveredCloseTabKey} - setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} closingTabIds={closingTabIds} onNavigateTab={onNavigateTab} @@ -862,7 +861,6 @@ function SplitPaneView({ normalizedWorkspaceId, isWorkspaceFocused, hoveredCloseTabKey, - setHoveredTabKey, setHoveredCloseTabKey, closingTabIds, onNavigateTab, @@ -995,66 +993,67 @@ function SplitPaneView({ ); return ( - - - - - + + + + + + - - {mountedPaneTabIds.length > 0 - ? mountedPaneTabIds.map((tabId) => { - const tabDescriptor = tabDescriptorMap.get(tabId); - if (!tabDescriptor) { - return null; - } + + {mountedPaneTabIds.length > 0 + ? mountedPaneTabIds.map((tabId) => { + const tabDescriptor = tabDescriptorMap.get(tabId); + if (!tabDescriptor) { + return null; + } - return ( - - ); - }) - : (renderPaneEmptyState?.() ?? null)} - + return ( + + ); + }) + : (renderPaneEmptyState?.() ?? null)} + + - + ); } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f308f5de5..1b73bb1c8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { SquarePen } from "lucide-react-native"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -38,6 +38,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { RenderProfile } from "@/utils/render-profiler"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, @@ -1095,42 +1096,52 @@ function ChatAgentReadyContent({ agentId, }), }); + const streamSection = ( + + + + ); + const composerSection = ( + + + + ); + const streamContent = ( + {streamSection} + ); + const contentContainer = {streamContent}; return ( - - - - - + {contentContainer} - + {composerSection} {showHistorySyncOverlay ? ( @@ -1158,7 +1169,7 @@ function ChatAgentReadyContent({ ); } -function AgentStreamSection({ +const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, agentId, @@ -1222,7 +1233,7 @@ function AgentStreamSection({ onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); -} +}); function AgentComposerSection({ agentId, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 8e8d9f8d8..ed53201d0 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -62,6 +62,7 @@ import { } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { Theme } from "@/styles/theme"; +import { RenderProfile } from "@/utils/render-profiler"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; @@ -148,7 +149,6 @@ interface WorkspaceDesktopTabsRowProps { tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; - setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; @@ -255,7 +255,6 @@ function TabChip({ presentation, tooltipLabel, resolvedTab, - setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, @@ -273,7 +272,6 @@ function TabChip({ presentation: WorkspaceTabPresentation; tooltipLabel: string; resolvedTab: WorkspaceDesktopTabActions; - setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; @@ -311,13 +309,11 @@ function TabChip({ const handleTabHoverIn = useCallback(() => { setHovered(true); - setHoveredTabKey(tab.key); - }, [setHoveredTabKey, tab.key]); + }, []); const handleTabHoverOut = useCallback(() => { setHovered(false); - setHoveredTabKey((current) => (current === tab.key ? null : current)); - }, [setHoveredTabKey, tab.key]); + }, []); const handleNavigateTab = useCallback(() => { onNavigateTab(tab.tabId); @@ -328,14 +324,12 @@ function TabChip({ }, []); const handleCloseButtonHoverIn = useCallback(() => { - setHoveredTabKey(tab.key); setHoveredCloseTabKey(tab.key); - }, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]); + }, [setHoveredCloseTabKey, tab.key]); const handleCloseButtonHoverOut = useCallback(() => { - setHoveredTabKey((current) => (current === tab.key ? null : current)); setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); - }, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]); + }, [setHoveredCloseTabKey, tab.key]); const handleCloseButtonPress = useCallback( (event: { stopPropagation?: () => void }) => { @@ -466,7 +460,6 @@ export function WorkspaceDesktopTabsRow({ tabs, normalizedServerId, normalizedWorkspaceId, - setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, @@ -613,7 +606,6 @@ export function WorkspaceDesktopTabsRow({ resolvedTabWidth={resolvedTabWidth} showLabel={showLabel} showCloseButton={shouldShowCloseButton} - setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={onNavigateTab} onCloseTab={onCloseTab} @@ -640,7 +632,6 @@ export function WorkspaceDesktopTabsRow({ onReloadAgent, onRenameTab, setHoveredCloseTabKey, - setHoveredTabKey, tabDropPreviewIndex, tabs.length, ], @@ -656,7 +647,7 @@ export function WorkspaceDesktopTabsRow({ [layout.requiresHorizontalScrollFallback], ); - return ( + const row = ( ); -} + return {row}; +} function ResolvedDesktopTabChip({ item, isFocused, @@ -807,7 +799,6 @@ function ResolvedDesktopTabChip({ resolvedTabWidth, showLabel, showCloseButton, - setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, @@ -832,7 +823,6 @@ function ResolvedDesktopTabChip({ resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; - setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; @@ -896,7 +886,6 @@ function ResolvedDesktopTabChip({ presentation={presentation} tooltipLabel={tooltipLabel} resolvedTab={resolvedTab} - setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={onNavigateTab} onCloseTab={onCloseTab} diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 3987980fd..fd40efeb0 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -6,9 +6,11 @@ import { PaneProvider, type PaneContextValue, } from "@/panels/pane-context"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { RenderProfile } from "@/utils/render-profiler"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface WorkspacePaneContentModel { @@ -72,6 +74,35 @@ export function WorkspacePaneContent({ onFocusPane, }: WorkspacePaneContentProps) { const { Component, key, paneContextValue } = content; + const openTab = useStableEvent(paneContextValue.openTab); + const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab); + const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab); + const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace); + const openImportSheet = useStableEvent(paneContextValue.openImportSheet); + const stablePaneContextValue = useMemo( + () => ({ + serverId: paneContextValue.serverId, + workspaceId: paneContextValue.workspaceId, + tabId: paneContextValue.tabId, + target: paneContextValue.target, + openTab, + closeCurrentTab, + retargetCurrentTab, + openFileInWorkspace, + openImportSheet, + }), + [ + closeCurrentTab, + openFileInWorkspace, + openImportSheet, + openTab, + paneContextValue.serverId, + paneContextValue.tabId, + paneContextValue.target, + paneContextValue.workspaceId, + retargetCurrentTab, + ], + ); const paneFocusValue = useMemo( () => createPaneFocusContextValue({ @@ -83,10 +114,14 @@ export function WorkspacePaneContent({ ); return ( - - - - - + + + + + + + ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a32931b61..cce5f5029 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -177,6 +177,7 @@ import { type WorkspaceFileLocation, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; +import { RenderProfile } from "@/utils/render-profiler"; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; @@ -734,16 +735,43 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ : styles.mobileMountedTabSlotHidden; return ( - - - + + + + + ); }); +interface MobileExplorerOpenGestureSurfaceProps { + children: ReactNode; + onOpenExplorer: () => void; +} + +function MobileExplorerOpenGestureSurface({ + children, + onOpenExplorer, +}: MobileExplorerOpenGestureSurfaceProps) { + const canOpenExplorerFromAgentView = usePanelStore( + (state) => + state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }), + ); + const explorerOpenGesture = useExplorerOpenGesture({ + enabled: canOpenExplorerFromAgentView, + onOpen: onOpenExplorer, + }); + + return ( + + {children} + + ); +} + function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { const cacheRef = useRef(new Map()); const tabDescriptorMap = useMemo(() => { @@ -770,7 +798,11 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { return tabDescriptorMap; } -export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) { +export const WorkspaceScreen = memo(function WorkspaceScreen({ + serverId, + workspaceId, + isRouteFocused, +}: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); const effectiveRouteFocused = isRouteFocused ?? navigationFocused; @@ -783,7 +815,7 @@ export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: Works /> ); -} +}); interface UseCloseTabsResult { closingTabIds: Set; @@ -1657,10 +1689,6 @@ function WorkspaceScreenContent({ const isExplorerOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }), ); - const canOpenExplorerFromAgentView = usePanelStore( - (state) => - state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }), - ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const toggleFileExplorerForCheckout = usePanelStore( (state) => state.toggleFileExplorerForCheckout, @@ -1712,11 +1740,6 @@ function WorkspaceScreenContent({ [isExplorerOpen], ); - const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && canOpenExplorerFromAgentView, - onOpen: openExplorerForWorkspace, - }); - useEffect(() => { if (!isRouteFocused || isWeb || !isExplorerOpen) { return; @@ -2178,7 +2201,31 @@ function WorkspaceScreenContent({ ], ); - const [_hoveredTabKey, setHoveredTabKey] = useState(null); + const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ + request, + paneId, + parentTabId, + focusPaneBeforeOpen, + }: { + request: WorkspaceFileOpenRequest; + paneId?: string | null; + parentTabId: string; + focusPaneBeforeOpen?: boolean; + }) { + if (focusPaneBeforeOpen && paneId && persistenceKey) { + focusWorkspacePane(persistenceKey, paneId); + } + if (request.disposition === "side") { + handleOpenFileFromChatInSidePane({ + location: request.location, + sourcePaneId: paneId ?? undefined, + parentTabId, + }); + return; + } + handleOpenFileFromChat(request.location, { parentTabId }); + }); + const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(null); const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } = useWorkspaceTabRename({ @@ -2300,7 +2347,6 @@ function WorkspaceScreenContent({ } removeTerminalFromCache(terminalId); - setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ @@ -2349,7 +2395,6 @@ function WorkspaceScreenContent({ } } - setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ @@ -2374,7 +2419,6 @@ function WorkspaceScreenContent({ tabId: string; target?: WorkspaceTabTarget | null; }) { - setHoveredTabKey((current) => (current === input.tabId ? null : current)); setHoveredCloseTabKey((current) => (current === input.tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target }); @@ -2556,7 +2600,6 @@ function WorkspaceScreenContent({ }); const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); - setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current)); setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); }, [client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey], @@ -2851,26 +2894,19 @@ function WorkspaceScreenContent({ retargetWorkspaceTab(persistenceKey, input.tab.tabId, target); }, onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => { - if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) { - focusWorkspacePane(persistenceKey, input.paneId); - } - if (request.disposition === "side") { - handleOpenFileFromChatInSidePane({ - location: request.location, - sourcePaneId: input.paneId ?? undefined, - parentTabId: input.tab.tabId, - }); - return; - } - handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId }); + handleOpenWorkspaceFileFromPane({ + request, + paneId: input.paneId, + parentTabId: input.tab.tabId, + focusPaneBeforeOpen: input.focusPaneBeforeOpen, + }); }, onOpenImportSheet: openImportSheet, }), [ handleCloseTabById, - handleOpenFileFromChat, - handleOpenFileFromChatInSidePane, focusWorkspacePane, + handleOpenWorkspaceFileFromPane, navigateToTabId, normalizedServerId, normalizedWorkspaceId, @@ -3193,9 +3229,9 @@ function WorkspaceScreenContent({ `${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`, [normalizedServerId, normalizedWorkspaceId], ); - const desktopContent = useMemo(() => { + const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { - return content; + return null; } return ( ); }, [ - content, canRenderDesktopPaneSplits, workspaceLayout, persistenceKey, @@ -3267,6 +3301,7 @@ function WorkspaceScreenContent({ handleReorderTabsInPane, renderSplitPaneEmptyState, ]); + const desktopContent = desktopSplitContent ?? content; const workspaceCenterColumn = ( @@ -3343,7 +3378,6 @@ function WorkspaceScreenContent({ tabs={desktopTabRowItems} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} - setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={navigateToTabId} onCloseTab={handleCloseTabById} @@ -3369,12 +3403,9 @@ function WorkspaceScreenContent({ {isMobile ? ( - - {content} - + + {content} + ) : ( {desktopContent} )} @@ -3385,44 +3416,46 @@ function WorkspaceScreenContent({ return ( gatedWorkspaceScreen ?? ( - - - - - {workspaceCenterColumn} - + + + + + + {workspaceCenterColumn} + - + - {showExplorerSidebar && workspaceDirectory ? ( - - ) : null} + {showExplorerSidebar && workspaceDirectory ? ( + + ) : null} + + + - - - + ) ); diff --git a/packages/app/src/utils/render-profiler.tsx b/packages/app/src/utils/render-profiler.tsx new file mode 100644 index 000000000..0144ba67a --- /dev/null +++ b/packages/app/src/utils/render-profiler.tsx @@ -0,0 +1,81 @@ +import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react"; + +export interface RenderProfileSample { + id: string; + phase: "mount" | "update" | "nested-update"; + actualDuration: number; + baseDuration: number; + startTime: number; + commitTime: number; +} + +declare global { + var __PASEO_RENDER_PROFILE__: RenderProfileSample[] | undefined; + var __PASEO_RENDER_PROFILE_REASONS__: Record> | undefined; + var __PASEO_RESET_RENDER_PROFILE__: (() => void) | undefined; +} + +function getSearchParam(name: string): string | null { + const location = Reflect.get(globalThis, "location"); + if (!location || typeof location !== "object") { + return null; + } + const search = Reflect.get(location, "search"); + if (typeof search !== "string") { + return null; + } + return new URLSearchParams(search).get(name); +} + +function isRenderProfileEnabled(): boolean { + return getSearchParam("renderProfile") === "1"; +} + +const onRender: ProfilerOnRenderCallback = ( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, +) => { + globalThis.__PASEO_RENDER_PROFILE__ ??= []; + globalThis.__PASEO_RENDER_PROFILE__.push({ + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + }); +}; + +export function RenderProfile({ id, children }: { id: string; children: ReactNode }) { + if (!isRenderProfileEnabled()) { + return children; + } + + globalThis.__PASEO_RENDER_PROFILE__ ??= []; + globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; + globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => { + globalThis.__PASEO_RENDER_PROFILE__ = []; + globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; + }; + + return ( + + {children} + + ); +} + +export function recordRenderProfileReasons(id: string, reasons: string[]) { + if (!isRenderProfileEnabled() || reasons.length === 0) { + return; + } + globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; + const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {}); + for (const reason of reasons) { + counts[reason] = (counts[reason] ?? 0) + 1; + } +}