perf(renderer): avoid runtime graph work on title ticks (#1903)

This commit is contained in:
Neil 2026-05-15 00:05:42 -07:00 committed by GitHub
parent 7d901f7809
commit f71397b1b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 60 deletions

View File

@ -617,11 +617,10 @@ function App(): React.JSX.Element {
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {
// Why: skip the key build entirely when no input field has changed by
// reference. Mirrors every field used by getRuntimeMobileSessionSyncKey
// so this gate covers every "could the key have changed?" case.
// — if any field's reference is unchanged, neither the projection
// serialized from it nor the reference-compared map can have changed.
// Why: skip the key build entirely when every input field is unchanged
// by reference. Mirrors every field used by
// getRuntimeMobileSessionSyncKey so this gate covers every "could the
// key have changed?" case.
if (
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
@ -638,7 +637,7 @@ function App(): React.JSX.Element {
) {
return
}
const nextKey = getRuntimeMobileSessionSyncKey(state)
const nextKey = getRuntimeMobileSessionSyncKey(state, previousState, previousKey)
if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) {
return
}

View File

@ -8,6 +8,13 @@ export type VisibleTabRef = {
tabId?: string
}
export type ActiveTabNavOrderIds = {
terminalIds?: string[]
editorIds?: string[]
browserIds?: string[]
notesIds?: string[]
}
/**
* Compute the visible tab-strip order for a single group.
*
@ -116,14 +123,19 @@ export function getActiveTabNavOrder(
| 'openFiles'
| 'browserTabsByWorktree'
>,
worktreeId: string
worktreeId: string,
ids: ActiveTabNavOrderIds = {}
): VisibleTabRef[] {
const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? [])
.filter((tab) => tab.contentType === 'notes')
.map((tab) => tab.id)
const terminalIds = ids.terminalIds ?? (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const editorIds =
ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
const browserIds =
ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id)
const notesIds =
ids.notesIds ??
(state.unifiedTabsByWorktree?.[worktreeId] ?? [])
.filter((tab) => tab.contentType === 'notes')
.map((tab) => tab.id)
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
const group = activeGroupId

View File

@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildMobileSessionTabSnapshots,
getRuntimeMobileSessionSyncKey,
runtimeMobileSessionSyncKeysEqual
} from './sync-runtime-graph'
import type { AppState } from '../store/types'
function makeState(overrides: Partial<AppState> = {}): AppState {
return {
tabsByWorktree: {},
terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'],
runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'],
groupsByWorktree: {},
activeGroupIdByWorktree: {},
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
...overrides
} as AppState
}
function makeOpenMarkdownFile(): AppState['openFiles'][number] {
return {
id: '/repo/README.md',
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit',
isDirty: false
}
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('runtime mobile session sync key projection reuse', () => {
it('reuses serialized projections when only runtime pane titles change', () => {
const base = makeState({
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }]
} as unknown as AppState['tabsByWorktree'],
runtimePaneTitlesByTabId: {
'term-1': { 1: 'Codex working' }
} as unknown as AppState['runtimePaneTitlesByTabId'],
openFiles: [makeOpenMarkdownFile()],
editorDrafts: { '/repo/README.md': '# draft' }
})
const baseKey = getRuntimeMobileSessionSyncKey(base)
const titleTick = makeState({
...base,
runtimePaneTitlesByTabId: {
'term-1': { 1: 'Codex spinner frame' }
} as unknown as AppState['runtimePaneTitlesByTabId']
})
const stringifySpy = vi.spyOn(JSON, 'stringify')
stringifySpy.mockClear()
const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey)
expect(stringifySpy).not.toHaveBeenCalled()
expect(titleTickKey.tabsProjection).toBe(baseKey.tabsProjection)
expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection)
expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection)
expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false)
})
it('only rebuilds the tab projection when a terminal tab title changes', () => {
const base = makeState({
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }]
} as unknown as AppState['tabsByWorktree'],
openFiles: [makeOpenMarkdownFile()],
editorDrafts: { '/repo/README.md': '# draft' }
})
const baseKey = getRuntimeMobileSessionSyncKey(base)
const titleTick = makeState({
...base,
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'Codex spinner frame', customTitle: null }]
} as unknown as AppState['tabsByWorktree']
})
const stringifySpy = vi.spyOn(JSON, 'stringify')
stringifySpy.mockClear()
const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey)
expect(stringifySpy).toHaveBeenCalledTimes(1)
expect(titleTickKey.tabsProjection).not.toBe(baseKey.tabsProjection)
expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection)
expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection)
expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false)
})
})
describe('mobile session snapshot reuse', () => {
it('reuses draft document versions when only runtime pane titles change', () => {
const draft = {
length: 1,
charCodeAt: vi.fn(() => 120)
} as unknown as string
const draftSpy = (draft as unknown as { charCodeAt: ReturnType<typeof vi.fn> }).charCodeAt
const base = makeState({
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }]
} as unknown as AppState['tabsByWorktree'],
tabBarOrderByWorktree: { 'wt-1': ['term-1', '/repo/README.md'] },
runtimePaneTitlesByTabId: {
'term-1': { 1: 'Codex working' }
} as unknown as AppState['runtimePaneTitlesByTabId'],
openFiles: [makeOpenMarkdownFile()],
editorDrafts: { '/repo/README.md': draft },
activeFileId: '/repo/README.md'
})
buildMobileSessionTabSnapshots(base)
expect(draftSpy).toHaveBeenCalled()
draftSpy.mockClear()
buildMobileSessionTabSnapshots({
...base,
runtimePaneTitlesByTabId: {
'term-1': { 1: 'Codex spinner frame' }
} as unknown as AppState['runtimePaneTitlesByTabId']
})
expect(draftSpy).not.toHaveBeenCalled()
})
})

View File

@ -25,6 +25,10 @@ type RegisteredTerminalTab = {
}
type OpenFileByWorktreeAndId = Map<string, Map<string, AppState['openFiles'][number]>>
type OpenFileIndexes = {
byWorktreeAndId: OpenFileByWorktreeAndId
idsByWorktree: Map<string, string[]>
}
const registeredTabs = new Map<string, RegisteredTerminalTab>()
// Why: track when each tab was registered so we can suppress the "no live
@ -38,6 +42,10 @@ let syncScheduled = false
let syncEnabled = false
let getStoreState: (() => AppState) | null = null
let mobileSessionSnapshotVersion = 0
let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null
let cachedOpenFileIndexes: OpenFileIndexes | null = null
let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null
let cachedEditorDraftVersionByFileId: Map<string, string> | null = null
const mobileSessionPublicationEpoch =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
@ -120,7 +128,13 @@ export type RuntimeMobileSessionSyncKey = {
editorDraftsProjection: string
}
export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSessionSyncKey {
export function getRuntimeMobileSessionSyncKey(
state: AppState,
previousState?: AppState,
previousKey?: RuntimeMobileSessionSyncKey
): RuntimeMobileSessionSyncKey {
const canReusePrevious = previousState !== undefined && previousKey !== undefined
return {
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
@ -130,43 +144,66 @@ export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSe
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
activeFileId: state.activeFileId,
activeFileIdByWorktree: state.activeFileIdByWorktree,
tabsProjection: JSON.stringify(
Object.fromEntries(
Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => ({
id: tab.id,
title: tab.title,
customTitle: tab.customTitle,
active: state.activeTabId === tab.id
}))
])
)
),
openFilesProjection: JSON.stringify(
state.openFiles.map((file) => ({
id: file.id,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
isDirty: file.isDirty,
isUntitled: file.isUntitled,
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
}))
),
editorDraftsProjection: JSON.stringify(
Object.fromEntries(
Object.entries(state.editorDrafts).map(([fileId, content]) => [
fileId,
stableHashString(content)
])
)
)
// Why: background agent title ticks can change runtimePaneTitlesByTabId
// many times per second while the user types elsewhere. Reuse unchanged
// projections so those ticks do not rescan all tabs, files, and drafts.
tabsProjection:
canReusePrevious &&
state.tabsByWorktree === previousState.tabsByWorktree &&
state.activeTabId === previousState.activeTabId
? previousKey.tabsProjection
: buildRuntimeMobileTabsProjection(state),
openFilesProjection:
canReusePrevious && state.openFiles === previousState.openFiles
? previousKey.openFilesProjection
: buildRuntimeMobileOpenFilesProjection(state.openFiles),
editorDraftsProjection:
canReusePrevious && state.editorDrafts === previousState.editorDrafts
? previousKey.editorDraftsProjection
: buildRuntimeMobileEditorDraftsProjection(state.editorDrafts)
}
}
function buildRuntimeMobileTabsProjection(state: AppState): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => ({
id: tab.id,
title: tab.title,
customTitle: tab.customTitle,
active: state.activeTabId === tab.id
}))
])
)
)
}
function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string {
return JSON.stringify(
openFiles.map((file) => ({
id: file.id,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
isDirty: file.isDirty,
isUntitled: file.isUntitled,
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
}))
)
}
function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)])
)
)
}
export function runtimeMobileSessionSyncKeysEqual(
a: RuntimeMobileSessionSyncKey,
b: RuntimeMobileSessionSyncKey
@ -267,10 +304,11 @@ async function syncRuntimeGraph(): Promise<void> {
export function buildMobileSessionTabSnapshots(
state: AppState
): RuntimeMobileSessionTabsSnapshot[] {
// Why: mobile publication walks the tab order for every worktree. A single
// worktree-scoped file map keeps large editor sessions linear without
// collapsing SSH worktrees that expose the same absolute remote path.
const openFileByWorktreeAndId = indexOpenFilesByWorktreeAndId(state.openFiles)
// Why: mobile publication can run on high-frequency background agent title
// ticks. Cache open-file indexes and draft hashes by immutable store-slice
// reference so title-only syncs do not rescan or rehash editor state.
const openFileIndexes = getOpenFileIndexes(state.openFiles)
const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts)
const worktreeIds = new Set<string>([
...Object.keys(state.tabsByWorktree),
...Object.keys(state.groupsByWorktree),
@ -281,7 +319,9 @@ export function buildMobileSessionTabSnapshots(
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
for (const worktreeId of worktreeIds) {
const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null
const order = getActiveTabNavOrder(state, worktreeId)
const order = getActiveTabNavOrder(state, worktreeId, {
editorIds: openFileIndexes.idsByWorktree.get(worktreeId) ?? []
})
const terminalTabByIdForWorktree = new Map(
(state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
@ -295,11 +335,17 @@ export function buildMobileSessionTabSnapshots(
}
tabs.push(...buildMobileTerminalSurfaceTabs(state, terminal, worktreeId, item.tabId))
} else if (item.type === 'editor') {
const file = openFileByWorktreeAndId.get(worktreeId)?.get(item.id)
const file = openFileIndexes.byWorktreeAndId.get(worktreeId)?.get(item.id)
if (!file) {
continue
}
const markdown = buildMobileMarkdownTab(state, openFileByWorktreeAndId, file, item.tabId)
const markdown = buildMobileMarkdownTab(
state,
openFileIndexes.byWorktreeAndId,
editorDraftVersionByFileId,
file,
item.tabId
)
if (markdown) {
tabs.push(markdown)
} else {
@ -323,19 +369,49 @@ export function buildMobileSessionTabSnapshots(
return snapshots
}
function indexOpenFilesByWorktreeAndId(openFiles: AppState['openFiles']): OpenFileByWorktreeAndId {
function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes {
if (cachedOpenFileIndexesSource === openFiles && cachedOpenFileIndexes) {
return cachedOpenFileIndexes
}
const byWorktreeAndId: OpenFileByWorktreeAndId = new Map()
const idsByWorktree = new Map<string, string[]>()
for (const file of openFiles) {
let filesById = byWorktreeAndId.get(file.worktreeId)
if (!filesById) {
filesById = new Map()
byWorktreeAndId.set(file.worktreeId, filesById)
}
let ids = idsByWorktree.get(file.worktreeId)
if (!ids) {
ids = []
idsByWorktree.set(file.worktreeId, ids)
}
if (!filesById.has(file.id)) {
filesById.set(file.id, file)
ids.push(file.id)
}
}
return byWorktreeAndId
cachedOpenFileIndexesSource = openFiles
cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree }
return cachedOpenFileIndexes
}
function getEditorDraftVersionByFileId(
editorDrafts: AppState['editorDrafts']
): Map<string, string> {
if (cachedEditorDraftsSource === editorDrafts && cachedEditorDraftVersionByFileId) {
return cachedEditorDraftVersionByFileId
}
const versions = new Map<string, string>()
for (const [fileId, content] of Object.entries(editorDrafts)) {
versions.set(fileId, stableHashString(content))
}
cachedEditorDraftsSource = editorDrafts
cachedEditorDraftVersionByFileId = versions
return versions
}
function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string {
@ -399,6 +475,7 @@ function buildMobileTerminalSurfaceTabs(
function buildMobileMarkdownTab(
state: AppState,
openFileByWorktreeAndId: OpenFileByWorktreeAndId,
editorDraftVersionByFileId: ReadonlyMap<string, string>,
file: AppState['openFiles'][number],
unifiedTabId?: string
): RuntimeMobileSessionMarkdownTab | null {
@ -414,7 +491,7 @@ function buildMobileMarkdownTab(
? (openFileByWorktreeAndId.get(file.worktreeId)?.get(file.markdownPreviewSourceFileId) ??
file)
: file
const draftContent = state.editorDrafts[sourceFile.id]
const draftVersion = editorDraftVersionByFileId.get(sourceFile.id)
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown'
return {
@ -434,8 +511,7 @@ function buildMobileMarkdownTab(
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
documentVersion:
draftContent !== undefined ? stableHashString(draftContent) : `file:${sourceFile.id}`
documentVersion: draftVersion ?? `file:${sourceFile.id}`
}
}