diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index badf286aa24..75ee7dc7085 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -605,7 +605,6 @@ 1 src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh-effect.ts 5 src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh.ts 3 src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts -3 src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts 4 src/renderer/src/components/task-page/github/use-task-page-work-items-cache-selection.ts 1 src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts 2 src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx diff --git a/config/react-compiler-hazard-baseline.txt b/config/react-compiler-hazard-baseline.txt index f468d574c90..f481622de5d 100644 --- a/config/react-compiler-hazard-baseline.txt +++ b/config/react-compiler-hazard-baseline.txt @@ -1,23 +1,4 @@ # Grandfathered code React Compiler would run differently (#:). # Generated; never hand-edit. Lower after a fix: pnpm check:react-compiler-compat-ratchet --prune -2 src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx#LinearIssueMarkdownDescriptionEditor:void-read -1 src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts#useBrowserPageAnnotationSend:void-read 1 src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.ts#useAnyBrowserPageMountAdmission:void-read -1 src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx#NotesSendMenu:void-read -1 src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx#ReviewNotesAnimatedVisual:void-read -1 src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx#ReviewShipAnimatedVisual:void-read -1 src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx#OrchestrationPage:void-read -1 src/renderer/src/components/feature-wall/review-pr-view-animation.ts#useReviewPrViewAnimation:void-read -1 src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts#useEditorAnimatedVisualAnimation:void-read -1 src/renderer/src/components/mobile/PhoneCarousel.tsx#PhoneCarousel:void-read -1 src/renderer/src/components/native-chat/use-native-chat-can-send.ts#useNativeChatCanSend:void-read -2 src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx#WorktreeCardAgentsBody:void-read -2 src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.ts#useFolderWorkspaceComposerPathStatus:void-read -1 src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts#useAgentSendTargetWorktreeId:void-read -2 src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts#useFolderWorkspacePathStatusRows:void-read 1 src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.ts#useSleepingSweepRetention:void-read -1 src/renderer/src/components/status-bar/use-resource-pty-actions.ts#useResourcePtyActions:member-postfix-value -6 src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts#useTaskPagePRCheckHydration:void-read -11 src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts#useWorktreeJumpPaletteQuickActions:void-read -1 src/renderer/src/hooks/use-palette-search-evaluation-context.ts#usePaletteSearchEvaluationContext:void-read -1 src/renderer/src/hooks/useGitHubTaskItems.ts#useGitHubTaskProjectTable:void-read diff --git a/config/scripts/static-analysis/ratchets/react-compiler/react-compiler-hazards.mjs b/config/scripts/static-analysis/ratchets/react-compiler/react-compiler-hazards.mjs index b8cf32c969c..0c3dad12efa 100644 --- a/config/scripts/static-analysis/ratchets/react-compiler/react-compiler-hazards.mjs +++ b/config/scripts/static-analysis/ratchets/react-compiler/react-compiler-hazards.mjs @@ -12,8 +12,8 @@ const OPT_OUT_DIRECTIVES = new Set(['use no memo', 'use no forget']) export const HAZARD_FIXES = { 'void-read': [ 'React Compiler drops a bare `void x;` read, so it neither invalidates a memo nor forces layout.', - 'Make the value a real input instead (pass it to the code that needs it, or wrap the read in a', - 'helper call), and flush layout with a function call that returns the measured value.' + 'Memo invalidator: `useMemo(() => recomputeOn([tick], () => compute()), [tick])` from', + '@/lib/recompute-on. Layout flush: `forceLayoutReflow(el)` from @/lib/force-layout-reflow.' ], 'member-postfix-value': [ 'oxc React Compiler 0.152 compiles a member postfix update used as a value (`a[q.head++]`) as', diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index db18022887a..77ea3ddf6f7 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -22,6 +22,7 @@ import { getRichMarkdownSpellcheckAttribute, useRichMarkdownSpellcheckAttribute } from '@/components/editor/rich-markdown/instance/rich-markdown-spellcheck' +import { recomputeOn } from '@/lib/recompute-on' type LinearIssueMarkdownDescriptionEditorProps = { value: string @@ -64,15 +65,15 @@ export function LinearIssueMarkdownDescriptionEditor({ (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) // Recreate the private Marked registry with Tiptap on language changes to avoid accumulating tokenizers. - const codec = useMemo(() => { - void language - return createRichMarkdownEditorCodec() - }, [language]) - const linearIssueMarkdownExtensions = useMemo(() => { - // Language keys editor recreation because Tiptap freezes translated extension options. - void language - return createLinearIssueMarkdownExtensions(codec) - }, [codec, language]) + const codec = useMemo( + () => recomputeOn([language], () => createRichMarkdownEditorCodec()), + [language] + ) + // Language keys editor recreation because Tiptap freezes translated extension options. + const linearIssueMarkdownExtensions = useMemo( + () => recomputeOn([language], () => createLinearIssueMarkdownExtensions(codec)), + [codec, language] + ) const editor = useEditor( { diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts index 4a787717b23..2c05b687d6a 100644 --- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts @@ -12,6 +12,7 @@ import { isNoteInFlight, useNotesInFlightVersion } from '@/lib/notes-send-in-flight' +import { recomputeOn } from '@/lib/recompute-on' export function useBrowserPageAnnotationSend({ browserTabId, @@ -59,10 +60,13 @@ export function useBrowserPageAnnotationSend({ ) // Annotations another send holds are left out of the next one. const inFlightVersion = useNotesInFlightVersion() - const sendableAnnotations = useMemo(() => { - void inFlightVersion - return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation)) - }, [browserAnnotations, inFlightVersion]) + const sendableAnnotations = useMemo( + () => + recomputeOn([inFlightVersion], () => + browserAnnotations.filter((annotation) => !isNoteInFlight(annotation)) + ), + [browserAnnotations, inFlightVersion] + ) const browserAnnotationsPrompt = useMemo( () => formatBrowserAnnotationsAsMarkdown(sendableAnnotations, { diff --git a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx b/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx index 3b07f38ee1f..35370a4de05 100644 --- a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx @@ -24,6 +24,7 @@ import { useNotesInFlightVersion } from '@/lib/notes-send-in-flight' import { translate } from '@/i18n/i18n' +import { recomputeOn } from '@/lib/recompute-on' function enabledSendTooltip(): string { return translate( @@ -99,15 +100,18 @@ export function NotesSendMenu({ const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts]) const ownsTargetMode = useAppStateValue((s) => s.agentSendPopoverTargetMode?.id === targetModeId) const inFlightVersion = useNotesInFlightVersion() - const sendableScopes = useMemo[]>(() => { - void inFlightVersion - return scopes.map(({ formatPrompt, ...scope }) => { - const notes = owner - ? scope.notes.filter((note) => !isNoteInFlight(diffCommentSendKey(owner, note))) - : [] - return { ...scope, notes, prompt: notes.length > 0 ? formatPrompt(notes) : '' } - }) - }, [inFlightVersion, owner, scopes]) + const sendableScopes = useMemo[]>( + () => + recomputeOn([inFlightVersion], () => + scopes.map(({ formatPrompt, ...scope }) => { + const notes = owner + ? scope.notes.filter((note) => !isNoteInFlight(diffCommentSendKey(owner, note))) + : [] + return { ...scope, notes, prompt: notes.length > 0 ? formatPrompt(notes) : '' } + }) + ), + [inFlightVersion, owner, scopes] + ) const enabledScopes = useMemo( () => sendableScopes.filter((scope) => scope.notes.length > 0), [sendableScopes] diff --git a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx index ef0d1f95d5e..320d71fcff4 100644 --- a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx @@ -14,6 +14,7 @@ import { ReviewNotesVisualStyles } from './review-animated-visual-notes-styles' import { ReviewDiffRows } from './review-notes-diff-rows' import { resetTerminal, runTerminalPhase } from './review-notes-terminal-phase' import { translate } from '@/i18n/i18n' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' // The animation owns timed DOM mutations to avoid React overwriting intermediate beats. export function ReviewNotesAnimatedVisual(props: { reducedMotion: boolean }): JSX.Element { @@ -165,7 +166,7 @@ export function ReviewNotesAnimatedVisual(props: { reducedMotion: boolean }): JS cursor.classList.remove('is-visible', 'is-clicking') cursor.style.transition = 'none' cursor.style.transform = 'translate(-30px, 220px)' - void cursor.offsetWidth + forceLayoutReflow(cursor) cursor.style.transition = '' } diff --git a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx index 2df127d1d07..969fdcf7fc0 100644 --- a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx @@ -14,6 +14,7 @@ import { } from './review-animated-visual-shared' import { ReviewShipVisualStyles } from './review-animated-visual-ship-styles' import { translate } from '@/i18n/i18n' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' export function ReviewShipAnimatedVisual(props: { reducedMotion: boolean }): JSX.Element { const { reducedMotion } = props @@ -104,7 +105,7 @@ export function ReviewShipAnimatedVisual(props: { reducedMotion: boolean }): JSX cursor.classList.remove('is-visible', 'is-clicking') cursor.style.transition = 'none' cursor.style.transform = 'translate(-30px, 220px)' - void cursor.offsetWidth + forceLayoutReflow(cursor) cursor.style.transition = '' } diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx index 20fa367701e..c9df770b619 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx @@ -20,6 +20,7 @@ import { import { arrowPathFromCoordTo, bubblePathBetweenRows } from './orchestration-bubble-path' import { AgentRow, WorkspaceCard } from './orchestration-cards' import { translate } from '@/i18n/i18n' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' const INITIAL_CHILD_PENDING: RowPending = { 'child-codex': true, @@ -181,7 +182,7 @@ export function OrchestrationPage(props: { '' + '' layer.appendChild(bubble) - void bubble.offsetWidth + forceLayoutReflow(bubble) nextFrame(() => bubble.classList.add('in-flight')) later(() => { diff --git a/src/renderer/src/components/feature-wall/review-pr-view-animation.ts b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts index bda794340f9..508631a37b7 100644 --- a/src/renderer/src/components/feature-wall/review-pr-view-animation.ts +++ b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { RefObject } from 'react' import { translate } from '@/i18n/i18n' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' function getReviewPrAnimatedStatusCopy(): { pendingLabel: string @@ -133,7 +134,7 @@ export function useReviewPrViewAnimation( cursorEl.classList.remove('is-visible', 'is-clicking') cursorEl.style.transition = 'none' cursorEl.style.transform = 'translate(-30px, 220px)' - void cursorEl.offsetWidth + forceLayoutReflow(cursorEl) cursorEl.style.transition = '' checksBlockEl.classList.remove('is-visible') commentsBlockEl.classList.remove('is-visible') diff --git a/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts b/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts index a50cc28ac35..527f327bbf9 100644 --- a/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts +++ b/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts @@ -6,6 +6,7 @@ import { codeBlockHTML, type EditorAnimatedVisualRefs } from './editor-animated-visual-markup' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' export function useEditorAnimatedVisualAnimation( reducedMotion: boolean, @@ -165,7 +166,7 @@ export function useEditorAnimatedVisualAnimation( cursor.style.opacity = '0' cursor.style.transform = 'translate(-30px, 80px)' // Force reflow so the next transition takes effect. - void cursor.offsetWidth + forceLayoutReflow(cursor) cursor.style.transition = '' await wait(EDITOR_ANIMATION_TIMING.preHoverMs) if (cancelled) { diff --git a/src/renderer/src/components/mobile/PhoneCarousel.tsx b/src/renderer/src/components/mobile/PhoneCarousel.tsx index df747537e65..6a766818adb 100644 --- a/src/renderer/src/components/mobile/PhoneCarousel.tsx +++ b/src/renderer/src/components/mobile/PhoneCarousel.tsx @@ -4,6 +4,7 @@ import { HomeSlide } from './slides/HomeSlide' import { WorktreeListSlide } from './slides/WorktreeListSlide' import { TerminalSlide } from './slides/TerminalSlide' import { translate } from '@/i18n/i18n' +import { forceLayoutReflow } from '@/lib/force-layout-reflow' const DWELL_MS = 4500 const TAP_BEFORE_PUSH_MS = 240 @@ -92,7 +93,7 @@ export function PhoneCarousel(): React.JSX.Element { return } const id = requestAnimationFrame(() => { - void containerRef.current?.offsetHeight + forceLayoutReflow(containerRef.current) }) return () => cancelAnimationFrame(id) }, [phase]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-can-send.ts b/src/renderer/src/components/native-chat/use-native-chat-can-send.ts index 582d2e47333..8d74b4744b6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-can-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-can-send.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state' import { deriveNativeChatCanSend } from './native-chat-send-eligibility' +import { recomputeOn } from '@/lib/recompute-on' /** Subscribe to the external driver map so mobile-owned PTYs guard composer sends just like xterm input. */ export function useNativeChatCanSend(ptyId: string | null): boolean { @@ -16,8 +17,11 @@ export function useNativeChatCanSend(ptyId: string | null): boolean { }), [ptyId] ) - return useMemo(() => { - void driverTick - return deriveNativeChatCanSend(ptyId ? getDriverForPty(ptyId) : null) - }, [ptyId, driverTick]) + return useMemo( + () => + recomputeOn([driverTick], () => + deriveNativeChatCanSend(ptyId ? getDriverForPty(ptyId) : null) + ), + [ptyId, driverTick] + ) } diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx index bae96a59da5..e3a43d1fda3 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx @@ -30,6 +30,7 @@ import { ChildAgentsWithIdleGroup, buildCollapsedChildAgentSignalStates } from './worktree-card-idle-child-agents' +import { recomputeOn } from '@/lib/recompute-on' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -101,46 +102,48 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ ) const isAgentSendTargetModeActive = agentSendPopoverTargetMode !== null - const sendTargetsByPaneKey = useMemo(() => { - void agentStatusEpoch - void languageRevision - if (!isAgentSendTargetModeActive) { - return new Map< - string, - { status: 'eligible' | 'disabled' | 'sending'; disabledReason?: string } - >() - } + const sendTargetsByPaneKey = useMemo( + () => + recomputeOn([agentStatusEpoch, languageRevision], () => { + if (!isAgentSendTargetModeActive) { + return new Map< + string, + { status: 'eligible' | 'disabled' | 'sending'; disabledReason?: string } + >() + } - return new Map( - deriveRunningAgentSendTargets(sendTargetInputs, worktreeId).map((target) => [ - target.paneKey, - agentSendPopoverTargetMode?.status === 'sending' && - agentSendPopoverTargetMode.sendingPaneKey === target.paneKey - ? { - status: 'sending' as const, - disabledReason: translate( - 'auto.components.sidebar.WorktreeCardAgents.sendingTarget', - 'Sending...' - ) - } - : target.disabledReason - ? { - status: target.status, - disabledReason: agentSendDisabledReasonLabel(target.disabledReason) - } - : { status: target.status } - ]) - ) - }, [ - // Freshness timers advance this epoch without replacing the status map. - agentStatusEpoch, - languageRevision, - agentSendPopoverTargetMode?.sendingPaneKey, - agentSendPopoverTargetMode?.status, - isAgentSendTargetModeActive, - sendTargetInputs, - worktreeId - ]) + return new Map( + deriveRunningAgentSendTargets(sendTargetInputs, worktreeId).map((target) => [ + target.paneKey, + agentSendPopoverTargetMode?.status === 'sending' && + agentSendPopoverTargetMode.sendingPaneKey === target.paneKey + ? { + status: 'sending' as const, + disabledReason: translate( + 'auto.components.sidebar.WorktreeCardAgents.sendingTarget', + 'Sending...' + ) + } + : target.disabledReason + ? { + status: target.status, + disabledReason: agentSendDisabledReasonLabel(target.disabledReason) + } + : { status: target.status } + ]) + ) + }), + [ + // Freshness timers advance this epoch without replacing the status map. + agentStatusEpoch, + languageRevision, + agentSendPopoverTargetMode?.sendingPaneKey, + agentSendPopoverTargetMode?.status, + isAgentSendTargetModeActive, + sendTargetInputs, + worktreeId + ] + ) const handleSendTargetClick = useCallback( (paneKey: string) => { diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.ts index b9e388c30bf..0908efb4d7b 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.ts @@ -8,6 +8,7 @@ import { } from '@/lib/folder-workspace-path-status' import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspaces/folder-workspace-path-status' import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import { recomputeOn } from '@/lib/recompute-on' export function useFolderWorkspaceComposerPathStatus( projectGroup: ProjectGroup | null, @@ -63,9 +64,9 @@ export function useFolderWorkspaceComposerPathStatus( return null } // Subscribe to writes, but let the TTL-aware accessor decide whether negative results remain authoritative. - void cachedPathStatusEntry - void cacheExpiryTick - return getFreshFolderWorkspacePathStatus(pathStatusRequest, pathStatusRouteOptions) + return recomputeOn([cachedPathStatusEntry, cacheExpiryTick], () => + getFreshFolderWorkspacePathStatus(pathStatusRequest, pathStatusRouteOptions) + ) }, [ cachedPathStatusEntry, cacheExpiryTick, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts index eb0b0fd505e..d6c5adac6a1 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' +import { recomputeOn } from '@/lib/recompute-on' // Why: selectors that opt out of a slice must return one stable reference, or every store tick looks like a change. const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} @@ -36,34 +37,37 @@ export function useAgentSendTargetWorktreeId(): string | null { const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID ) - return useMemo(() => { - void agentTargetStatusEpoch - if (!agentSendPopoverTargetMode) { - return null - } - const targets = deriveRunningAgentSendTargets( - { - agentStatusByPaneKey: agentTargetStatusByPaneKey, - tabsByWorktree: agentTargetTabsByWorktree, - unifiedTabsByWorktree: agentTargetUnifiedTabsByWorktree, - terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, - ptyIdsByTabId: agentTargetPtyIdsByTabId, - runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId - }, - agentSendPopoverTargetMode.worktreeId - ) - return targets.some((target) => target.status === 'eligible') - ? agentSendPopoverTargetMode.worktreeId - : null - }, [ - // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. - agentTargetStatusEpoch, - agentSendPopoverTargetMode, - agentTargetStatusByPaneKey, - agentTargetTabsByWorktree, - agentTargetUnifiedTabsByWorktree, - agentTargetTerminalLayoutsByTabId, - agentTargetPtyIdsByTabId, - agentTargetRuntimePaneTitlesByTabId - ]) + return useMemo( + () => + recomputeOn([agentTargetStatusEpoch], () => { + if (!agentSendPopoverTargetMode) { + return null + } + const targets = deriveRunningAgentSendTargets( + { + agentStatusByPaneKey: agentTargetStatusByPaneKey, + tabsByWorktree: agentTargetTabsByWorktree, + unifiedTabsByWorktree: agentTargetUnifiedTabsByWorktree, + terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, + ptyIdsByTabId: agentTargetPtyIdsByTabId, + runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId + }, + agentSendPopoverTargetMode.worktreeId + ) + return targets.some((target) => target.status === 'eligible') + ? agentSendPopoverTargetMode.worktreeId + : null + }), + [ + // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. + agentTargetStatusEpoch, + agentSendPopoverTargetMode, + agentTargetStatusByPaneKey, + agentTargetTabsByWorktree, + agentTargetUnifiedTabsByWorktree, + agentTargetTerminalLayoutsByTabId, + agentTargetPtyIdsByTabId, + agentTargetRuntimePaneTitlesByTabId + ] + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts index 407694bc529..286e5da7c51 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts @@ -9,6 +9,7 @@ import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' import type { AppState } from '@/store/types' import { getFolderPathStatusRouteOptionsForRows } from './folder-path-status-routing' +import { recomputeOn } from '@/lib/recompute-on' type FolderPathStatusRequest = Parameters[0] @@ -166,23 +167,24 @@ export function useFolderWorkspacePathStatusRows(args: { getFolderWorkspacePathStatusCacheKey, projectGroups ]) - const getCachedFolderWorkspacePathStatus = useCallback( - (request: FolderPathStatusRequest) => { - const options = getFolderPathStatusRouteOptions(request) - if (!options) { - return null - } - const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) - // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. - void folderWorkspacePathStatuses[cacheKey] - void folderPathStatusCacheExpiryTick - return getFreshFolderWorkspacePathStatus(request, options) - }, + // Why: status writes and expiry ticks renew the getter so an expired negative status can't keep + // folder workspaces disabled while a refresh is in flight. + const getCachedFolderWorkspacePathStatus = useMemo( + () => + recomputeOn( + [folderWorkspacePathStatuses, folderPathStatusCacheExpiryTick], + () => (request: FolderPathStatusRequest) => { + const options = getFolderPathStatusRouteOptions(request) + if (!options) { + return null + } + return getFreshFolderWorkspacePathStatus(request, options) + } + ), [ folderWorkspacePathStatuses, folderPathStatusCacheExpiryTick, getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, getFreshFolderWorkspacePathStatus ] ) diff --git a/src/renderer/src/components/status-bar/use-resource-pty-actions.ts b/src/renderer/src/components/status-bar/use-resource-pty-actions.ts index 697a48e8340..32409465468 100644 --- a/src/renderer/src/components/status-bar/use-resource-pty-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-pty-actions.ts @@ -129,7 +129,9 @@ export function useResourcePtyActions(inventory: ReturnType { pending.active -= 1 diff --git a/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts b/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts index 460f5ee23e5..399baae9a83 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts @@ -27,6 +27,7 @@ import { deriveTaskPagePRCheckSummary } from '../task-page-pr-check-summary' import { PR_CHECKS_EAGER_PREFETCH_LIMIT } from '../task-page-source-context' import { getTaskPageRepoSourceContext } from '../../../lib/task-repo-source-context' import { sameOptionalGitHubOwnerRepo } from './task-page-github-review-model' +import { recomputeOn } from '@/lib/recompute-on' export function useTaskPagePRCheckHydration( model: TaskPageJiraCreationMetadataModel, filteredWorkItems: GitHubWorkItem[], @@ -57,19 +58,25 @@ export function useTaskPagePRCheckHydration( }) .join('|') ) - const checkReads = useMemo(() => { - // A new list query owns its own bounded demand reads. - void model.selectedReposKey - void model.taskRefreshNonce - void model.workItemsInvalidationNonce - void model.appliedTaskSearch - return new Map() - }, [ - model.selectedReposKey, - model.taskRefreshNonce, - model.workItemsInvalidationNonce, - model.appliedTaskSearch - ]) + // A new list query owns its own bounded demand reads. + const checkReads = useMemo( + () => + recomputeOn( + [ + model.selectedReposKey, + model.taskRefreshNonce, + model.workItemsInvalidationNonce, + model.appliedTaskSearch + ], + () => new Map() + ), + [ + model.selectedReposKey, + model.taskRefreshNonce, + model.workItemsInvalidationNonce, + model.appliedTaskSearch + ] + ) const activeCheckReads = useRef(null) useLayoutEffect(() => { activeCheckReads.current = checkReads @@ -79,125 +86,124 @@ export function useTaskPagePRCheckHydration( } } }, [checkReads]) - const ensurePRChecksLoaded = useCallback( - (item: GitHubWorkItem): void => { - if (item.type !== 'pr') { - return - } - // Capability observations wake the existing demand trigger without adding per-row subscriptions. - void localCapabilities - void runtimeStatuses - const repo = getWorkItemRepo(repos, item) - if (!repo) { - return - } - const sourceContext = getTaskPageRepoSourceContext(repo, 'github') - const runtimeContract = capturePRCheckContract( - useAppStore.getState, - getGitHubSourceRuntimeTarget(sourceContext), - sourceContext - ) - const generation = model.paginationGenerationRef.current - const contract = { - ...runtimeContract, - current: () => - runtimeContract.current() && - activeCheckReads.current === checkReads && - model.paginationGenerationRef.current === generation - } - if ( - item.checksSummary && - (!contract.complete || completePRCheckOwnerScope(item) === contract.scope) - ) { - return - } - const readKey = JSON.stringify([ - item.repoId, - item.id, - item.headSha, - sourceContext, - contract.complete - ]) - if (checkReads.get(readKey)?.current()) { - return - } - checkReads.set(readKey, contract) - // The query's loaded/demanded rows bound attempts; eviction must not re-arm failed eager reads. - const requestedHeadSha = item.headSha - const requestedPRRepo = item.prRepo ?? null - const owner = resolvePullRequestRepo(item) - const read = - contract.complete && owner - ? lookupGitHubWorkItemByOwnerRepoForSource({ - repoPath: repo.path, - repoId: repo.id, - sourceContext, - ...owner, - number: item.number, - type: 'pr', - accepts: contract.current, - checkContract: runtimeContract - }) - : fetchPRChecks( - repo.path, - item.number, - item.branchName, - item.headSha, - item.prRepo ?? null, - { + // Capability observations wake the existing demand trigger without adding per-row subscriptions. + const ensurePRChecksLoaded = useMemo( + () => + recomputeOn([localCapabilities, runtimeStatuses], () => (item: GitHubWorkItem): void => { + if (item.type !== 'pr') { + return + } + const repo = getWorkItemRepo(repos, item) + if (!repo) { + return + } + const sourceContext = getTaskPageRepoSourceContext(repo, 'github') + const runtimeContract = capturePRCheckContract( + useAppStore.getState, + getGitHubSourceRuntimeTarget(sourceContext), + sourceContext + ) + const generation = model.paginationGenerationRef.current + const contract = { + ...runtimeContract, + current: () => + runtimeContract.current() && + activeCheckReads.current === checkReads && + model.paginationGenerationRef.current === generation + } + if ( + item.checksSummary && + (!contract.complete || completePRCheckOwnerScope(item) === contract.scope) + ) { + return + } + const readKey = JSON.stringify([ + item.repoId, + item.id, + item.headSha, + sourceContext, + contract.complete + ]) + if (checkReads.get(readKey)?.current()) { + return + } + checkReads.set(readKey, contract) + // The query's loaded/demanded rows bound attempts; eviction must not re-arm failed eager reads. + const requestedHeadSha = item.headSha + const requestedPRRepo = item.prRepo ?? null + const owner = resolvePullRequestRepo(item) + const read = + contract.complete && owner + ? lookupGitHubWorkItemByOwnerRepoForSource({ + repoPath: repo.path, repoId: repo.id, - sourceContext - } + sourceContext, + ...owner, + number: item.number, + type: 'pr', + accepts: contract.current, + checkContract: runtimeContract + }) + : fetchPRChecks( + repo.path, + item.number, + item.branchName, + item.headSha, + item.prRepo ?? null, + { + repoId: repo.id, + sourceContext + } + ) + void read + .then((result) => { + if (!contract.current()) { + return + } + const observed = Array.isArray(result) ? null : result + if ( + contract.complete && + (!observed || + observed.headSha !== requestedHeadSha || + !sameOptionalGitHubOwnerRepo(observed.prRepo ?? null, requestedPRRepo)) + ) { + return + } + const checksSummary = Array.isArray(result) + ? deriveTaskPagePRCheckSummary(result) + : observed?.checksSummary + if (!checksSummary && !contract.complete) { + return + } + const accepted = + observed && + acceptPRCheckObservation(observed, item, { ...contract, current: contract.current() }) + patchTaskPageWorkItemRows( + { + id: item.id, + repoId: item.repoId + }, + contract.complete && accepted + ? projectPRCheckOwner(accepted, { + checksSummary: accepted.checksSummary, + number: item.number, + url: item.url, + prRepo: item.prRepo, + headSha: item.headSha + }) + : { checksSummary }, + (currentItem) => + currentItem.type === 'pr' && + (!contract.complete || currentItem.checksSummary === item.checksSummary) && + currentItem.headSha === requestedHeadSha && + sameOptionalGitHubOwnerRepo(currentItem.prRepo, requestedPRRepo) && + (currentItem.checksSummary?.cancelled === undefined || + currentItem.checksSummary.state !== checksSummary?.state || + contract.complete) ) - void read - .then((result) => { - if (!contract.current()) { - return - } - const observed = Array.isArray(result) ? null : result - if ( - contract.complete && - (!observed || - observed.headSha !== requestedHeadSha || - !sameOptionalGitHubOwnerRepo(observed.prRepo ?? null, requestedPRRepo)) - ) { - return - } - const checksSummary = Array.isArray(result) - ? deriveTaskPagePRCheckSummary(result) - : observed?.checksSummary - if (!checksSummary && !contract.complete) { - return - } - const accepted = - observed && - acceptPRCheckObservation(observed, item, { ...contract, current: contract.current() }) - patchTaskPageWorkItemRows( - { - id: item.id, - repoId: item.repoId - }, - contract.complete && accepted - ? projectPRCheckOwner(accepted, { - checksSummary: accepted.checksSummary, - number: item.number, - url: item.url, - prRepo: item.prRepo, - headSha: item.headSha - }) - : { checksSummary }, - (currentItem) => - currentItem.type === 'pr' && - (!contract.complete || currentItem.checksSummary === item.checksSummary) && - currentItem.headSha === requestedHeadSha && - sameOptionalGitHubOwnerRepo(currentItem.prRepo, requestedPRRepo) && - (currentItem.checksSummary?.cancelled === undefined || - currentItem.checksSummary.state !== checksSummary?.state || - contract.complete) - ) - }) - .catch(() => {}) - }, + }) + .catch(() => {}) + }), [ localCapabilities, runtimeStatuses, diff --git a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts index ecac85be453..41fc9dd0269 100644 --- a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts @@ -23,6 +23,7 @@ import type { WorktreeJumpPaletteOpenTabs } from './use-worktree-jump-palette-op import type { WorktreeJumpPaletteProjectTargets } from './use-worktree-jump-palette-project-targets' import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state' import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees' +import { recomputeOn } from '@/lib/recompute-on' function getComposerPrefetchRepoId( state: ReturnType, @@ -163,59 +164,67 @@ export function useWorktreeJumpPaletteQuickActions({ ] ) // Availability reads store state imperatively; these values supply its real memo dependencies, including managed-browser runtime status. - const availableActions = useMemo(() => { - void activeView - void activeWorktreeId - void worktreesByRepo - void repos - void sshConnectionStates - void activeGroupIdByWorktree - void groupsByWorktree - void unifiedTabsByWorktree - void isLoading - void settings?.activeRuntimeEnvironmentId - void runtimeStatusByEnvironmentId - const context = buildQuickActionContext() - return { - context, - actionResults: actionResults - .filter( - (action) => - (action.id === 'git-graph' && - context.activeWorktree && - repos.find((repo) => repo.id === context.activeWorktree?.repoId)?.kind !== - 'folder') || - action.isAvailable(context).available - ) - .map((action) => - action.id === 'git-graph' - ? { - ...action, - disabled: graphSupport !== true, - description: - graphSupport === false && activeWorktreeId - ? gitGraphUpdateMessage(activeWorktreeId) - : '' - } - : action - ) - } - }, [ - graphSupport, - actionResults, - buildQuickActionContext, - activeView, - activeWorktreeId, - worktreesByRepo, - repos, - sshConnectionStates, - activeGroupIdByWorktree, - groupsByWorktree, - unifiedTabsByWorktree, - isLoading, - settings?.activeRuntimeEnvironmentId, - runtimeStatusByEnvironmentId - ]) + const availableActions = useMemo( + () => + recomputeOn( + [ + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + unifiedTabsByWorktree, + isLoading, + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + ], + () => { + const context = buildQuickActionContext() + return { + context, + actionResults: actionResults + .filter( + (action) => + (action.id === 'git-graph' && + context.activeWorktree && + repos.find((repo) => repo.id === context.activeWorktree?.repoId)?.kind !== + 'folder') || + action.isAvailable(context).available + ) + .map((action) => + action.id === 'git-graph' + ? { + ...action, + disabled: graphSupport !== true, + description: + graphSupport === false && activeWorktreeId + ? gitGraphUpdateMessage(activeWorktreeId) + : '' + } + : action + ) + } + } + ), + [ + graphSupport, + actionResults, + buildQuickActionContext, + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + unifiedTabsByWorktree, + isLoading, + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + ] + ) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>(() => { const results = rankCmdJMiddleResults({ query: deferredQuery, diff --git a/src/renderer/src/hooks/use-palette-search-evaluation-context.ts b/src/renderer/src/hooks/use-palette-search-evaluation-context.ts index 30a41d0b342..f522ac2c7d0 100644 --- a/src/renderer/src/hooks/use-palette-search-evaluation-context.ts +++ b/src/renderer/src/hooks/use-palette-search-evaluation-context.ts @@ -3,12 +3,14 @@ import { createPaletteSearchContext, type PaletteSearchContext } from '../../../shared/palette-match/palette-ranking' +import { recomputeOn } from '@/lib/recompute-on' /** One clock for every source participating in the current search snapshot. */ export function usePaletteSearchEvaluationContext(snapshot: unknown): PaletteSearchContext { - return useMemo(() => { - void snapshot - // oxlint-disable-next-line react/purity -- Each changed snapshot starts one synchronous evaluation clock. - return createPaletteSearchContext(Date.now()) - }, [snapshot]) + return useMemo( + () => + // oxlint-disable-next-line react/purity -- Each changed snapshot starts one synchronous evaluation clock. + recomputeOn([snapshot], () => createPaletteSearchContext(Date.now())), + [snapshot] + ) } diff --git a/src/renderer/src/hooks/useGitHubTaskItems.ts b/src/renderer/src/hooks/useGitHubTaskItems.ts index 3ffb18a9b33..3ec3ad0f683 100644 --- a/src/renderer/src/hooks/useGitHubTaskItems.ts +++ b/src/renderer/src/hooks/useGitHubTaskItems.ts @@ -4,6 +4,7 @@ import { projectViewSourceScope } from '@/store/github/cache/cache-identity' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitHubProjectTable } from '../../../shared/github/project-types' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { recomputeOn } from '@/lib/recompute-on' const noopSubscribe = () => () => {} const noopSnapshot = () => 0 @@ -34,8 +35,9 @@ export function useGitHubTaskProjectTable( cache?.snapshot ?? noopSnapshot ) const scope = projectViewSourceScope(target) - return useMemo(() => { - void revision - return table && cache ? cache.projectTable(table, scope) : table - }, [cache, table, scope, revision]) + return useMemo( + () => + recomputeOn([revision], () => (table && cache ? cache.projectTable(table, scope) : table)), + [cache, table, scope, revision] + ) } diff --git a/src/renderer/src/lib/force-layout-reflow.ts b/src/renderer/src/lib/force-layout-reflow.ts new file mode 100644 index 00000000000..b49d51fcd35 --- /dev/null +++ b/src/renderer/src/lib/force-layout-reflow.ts @@ -0,0 +1,5 @@ +/** Flushes pending style changes so the next one starts a CSS transition. A call, unlike a bare + * `void el.offsetWidth`, survives React Compiler's dead-read removal. */ +export function forceLayoutReflow(element: HTMLElement | null | undefined): number | undefined { + return element?.offsetWidth +} diff --git a/src/renderer/src/lib/recompute-on.ts b/src/renderer/src/lib/recompute-on.ts new file mode 100644 index 00000000000..9241085f4c9 --- /dev/null +++ b/src/renderer/src/lib/recompute-on.ts @@ -0,0 +1,5 @@ +/** Returns `compute()`. `_inputs` are values a memo must recompute for although `compute` never + * reads them: React Compiler keys memos on what a call receives, and drops a bare `void x`. */ +export function recomputeOn(_inputs: readonly unknown[], compute: () => T): T { + return compute() +} diff --git a/src/renderer/src/runtime/agent-launch-settings/use-host-agent-launch-preferences.ts b/src/renderer/src/runtime/agent-launch-settings/use-host-agent-launch-preferences.ts index e42069319d9..394c2a44218 100644 --- a/src/renderer/src/runtime/agent-launch-settings/use-host-agent-launch-preferences.ts +++ b/src/renderer/src/runtime/agent-launch-settings/use-host-agent-launch-preferences.ts @@ -109,6 +109,30 @@ export async function ensureAgentLaunchPreferences( } } +// Takes the whole subscription so contact/open stay inputs; React Compiler keys a callback on the fields it reads. +function subscribeHostPreferences( + subscription: { + environmentId: string | undefined + pairingRevision: number | undefined + runtimeId: string | null | undefined + active: boolean + }, + listener: () => void +): () => void { + if (!subscription.environmentId || !subscription.active) { + return () => {} + } + return acquire( + resource({ + environmentId: subscription.environmentId, + pairingRevision: subscription.pairingRevision, + runtimeId: subscription.runtimeId ?? undefined + }), + listener, + true + ) +} + export function useHostAgentMenuSettings( owner: { executionHostId: string @@ -160,20 +184,7 @@ export function useHostAgentMenuSettings( pending ] = useAppViewExternalStore( useCallback( - (listener: () => void) => { - if (!subscription.environmentId || !subscription.active) { - return () => {} - } - return acquire( - resource({ - environmentId: subscription.environmentId, - pairingRevision: subscription.pairingRevision, - runtimeId: subscription.runtimeId ?? undefined - }), - listener, - true - ) - }, + (listener: () => void) => subscribeHostPreferences(subscription, listener), [subscription] ), () => effectiveSnapshot(undefined),