refactor(renderer): make memo invalidators and layout flushes compiler-safe (#39414)

React Compiler drops bare `void x;` reads, narrows callback deps to the fields
read, and oxc 0.152 emits `a[q.head++]` as a prefix update. Rewrite those
sites so they behave the same with the compiler on, and identically to today
with it off:

- `void tick` memo invalidators -> `recomputeOn([tick], () => ...)`
- `void el.offsetWidth` transition resets -> `forceLayoutReflow(el)`
- pty termination queue reads, then advances, the head index
- host agent preference subscription takes the whole subscription object

Prunes the compiler-compat hazard baseline from 40 to 2 entries.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-07 02:37:53 -08:00
committed by GitHub
co-authored by OrcaWin
parent b1221ed985
commit f6874cc512
25 changed files with 397 additions and 346 deletions
@@ -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
-19
View File
@@ -1,23 +1,4 @@
# Grandfathered code React Compiler would run differently (<file>#<function>:<hazard>).
# 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
@@ -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',
@@ -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(
{
@@ -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, {
@@ -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<TNote extends DiffCommentDeliverySnapshot>({
const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts])
const ownsTargetMode = useAppStateValue((s) => s.agentSendPopoverTargetMode?.id === targetModeId)
const inFlightVersion = useNotesInFlightVersion()
const sendableScopes = useMemo<SendableNotesScope<TNote>[]>(() => {
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<SendableNotesScope<TNote>[]>(
() =>
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]
@@ -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 = ''
}
@@ -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 = ''
}
@@ -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: {
'<rect x="3" y="5" width="18" height="14" rx="2"/>' +
'<path d="M3 7l9 6 9-6"/></svg>'
layer.appendChild(bubble)
void bubble.offsetWidth
forceLayoutReflow(bubble)
nextFrame(() => bubble.classList.add('in-flight'))
later(() => {
@@ -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')
@@ -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) {
@@ -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])
@@ -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]
)
}
@@ -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) => {
@@ -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,
@@ -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
]
)
}
@@ -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<AppState['fetchFolderWorkspacePathStatus']>[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
]
)
@@ -129,7 +129,9 @@ export function useResourcePtyActions(inventory: ReturnType<typeof useResourceSe
function drain(): void {
const pending = queue.current
while (pending.active < MAX_CONCURRENT_TERMINATIONS && pending.head < pending.items.length) {
const target = pending.items[pending.head++]!
// Why: oxc React Compiler 0.152 emits a member postfix update used as a value as a prefix one.
const target = pending.items[pending.head]!
pending.head += 1
pending.active += 1
void terminate(target).finally(() => {
pending.active -= 1
@@ -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<string, PRCheckContract>()
}, [
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<string, PRCheckContract>()
),
[
model.selectedReposKey,
model.taskRefreshNonce,
model.workItemsInvalidationNonce,
model.appliedTaskSearch
]
)
const activeCheckReads = useRef<typeof checkReads | null>(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,
@@ -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<typeof useAppStore.getState>,
@@ -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,
@@ -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]
)
}
+6 -4
View File
@@ -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]
)
}
@@ -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
}
+5
View File
@@ -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<T>(_inputs: readonly unknown[], compute: () => T): T {
return compute()
}
@@ -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),