perf(markdown): coalesce draft version hashing with publication (#35107)

* perf(markdown): coalesce draft version hashing with publication

* test(markdown): assert draft projection contents and drop test-local baseline

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 08:12:18 -07:00
committed by GitHub
co-authored by OrcaWin
parent 631d84d0af
commit 4d8071abb4
6 changed files with 295 additions and 79 deletions
@@ -0,0 +1,222 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { createTestStore } from '@/store/slices/store-test-helpers'
const mocks = vi.hoisted(() => ({
getState: vi.fn<() => AppState>(),
subscribe: vi.fn((_listener: (state: AppState, previous: AppState) => void) => () => {}),
publish: vi.fn<() => Promise<void>>(),
hashes: { calls: 0, chars: 0 }
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: AppState) => unknown) => selector(mocks.getState()),
{
getState: mocks.getState,
subscribe: mocks.subscribe
}
)
}))
vi.mock('../runtime/sync-runtime-graph/graph-publication', () => ({
syncRuntimeGraph: mocks.publish,
setTrailingGraphSyncScheduler: vi.fn()
}))
vi.mock('../runtime/sync-runtime-graph/editor-draft-hash', async (importOriginal) => {
const actual = await importOriginal<{ stableHashString: typeof stableHashString }>()
return {
stableHashString: (text: string) => {
mocks.hashes.calls += 1
mocks.hashes.chars += text.length
return actual.stableHashString(text)
}
}
})
import { useRuntimeGraphSync } from './use-runtime-graph-sync'
import { graphState } from '../runtime/sync-runtime-graph/graph-state'
import { buildMobileSessionTabSnapshots } from '../runtime/sync-runtime-graph/mobile-session-snapshots'
import { resetRuntimeMobileSyncProjectionCachesForTests } from '../runtime/sync-runtime-graph/sync-projections'
import { stableHashString } from '../runtime/sync-runtime-graph/editor-draft-hash'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types'
function Probe({ revision: _revision }: { revision: number }): null {
useRuntimeGraphSync()
return null
}
const file = {
id: 'markdown',
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit' as const,
isDirty: true
}
describe('runtime graph draft hashing at publication', () => {
let root: Root
let store: ReturnType<typeof createTestStore>
let snapshots: RuntimeMobileSessionTabsSnapshot[][]
let revision = 0
let releasePublication: (() => void) | undefined
async function render(): Promise<void> {
revision += 1
await act(async () => root.render(<Probe revision={revision} />))
}
async function flush(): Promise<void> {
await act(async () => vi.advanceTimersByTimeAsync(16))
}
function latestVersion(): string | undefined {
return snapshots
.at(-1)
?.flatMap((snapshot) => snapshot.tabs)
.find((tab) => tab.type === 'markdown')?.documentVersion
}
function resetCounts(): void {
mocks.hashes.calls = 0
mocks.hashes.chars = 0
mocks.publish.mockClear()
snapshots = []
}
beforeEach(() => {
releasePublication = undefined
vi.useFakeTimers()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
resetRuntimeMobileSyncProjectionCachesForTests()
graphState.mobileSessionSnapshotCacheByWorktree.clear()
store = createTestStore()
store.setState({
workspaceSessionReady: true,
openFiles: [file],
editorDrafts: { markdown: 'initial' }
})
mocks.getState.mockImplementation(store.getState)
mocks.subscribe.mockImplementation(store.subscribe)
snapshots = []
mocks.publish.mockImplementation(async () => {
snapshots.push(buildMobileSessionTabSnapshots(store.getState(), false))
})
resetCounts()
root = createRoot(document.createElement('div'))
})
afterEach(async () => {
await act(async () => {
root.unmount()
releasePublication?.()
})
vi.useRealTimers()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it.each([40_000, 400_000])(
'hashes only the final %i-character Markdown draft in a burst',
async (size) => {
store.setState({ editorDrafts: { markdown: 'x'.repeat(size) } })
await render()
await flush()
resetCounts()
for (let index = 1; index <= 100; index += 1) {
store.getState().setEditorDraft(file.id, 'x'.repeat(size + index))
}
expect(mocks.hashes.calls).toBe(0)
expect(mocks.publish).not.toHaveBeenCalled()
await flush()
expect(mocks.hashes).toEqual({ calls: 1, chars: size + 100 })
expect(mocks.publish).toHaveBeenCalledTimes(1)
const version = stableHashString(store.getState().editorDrafts.markdown)
expect(latestVersion()).toBe(version)
resetCounts()
store.setState({ editorDrafts: { ...store.getState().editorDrafts } })
await flush()
expect(mocks.publish).not.toHaveBeenCalled()
expect(mocks.hashes.calls).toBe(0)
}
)
it('defers disabled edits until re-enable and cancels pending work on unmount', async () => {
store.setState({ workspaceSessionReady: false })
await render()
resetCounts()
for (let index = 0; index < 100; index += 1) {
store.getState().setEditorDraft(file.id, `disabled-${index}`)
}
await flush()
expect(mocks.hashes.calls).toBe(0)
expect(mocks.publish).not.toHaveBeenCalled()
store.setState({ workspaceSessionReady: true })
await render()
await flush()
expect(latestVersion()).toBe(stableHashString('disabled-99'))
resetCounts()
store.getState().setEditorDraft(file.id, 'retired')
await act(async () => root.render(null))
await flush()
expect(mocks.hashes.calls).toBe(0)
expect(mocks.publish).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('publishes a trailing edit after an in-flight publication resolves', async () => {
await render()
await flush()
resetCounts()
mocks.publish.mockImplementationOnce(async () => {
snapshots.push(buildMobileSessionTabSnapshots(store.getState(), false))
await new Promise<void>((resolve) => {
releasePublication = resolve
})
})
store.getState().setEditorDraft(file.id, 'first')
await flush()
expect(latestVersion()).toBe(stableHashString('first'))
resetCounts()
store.setState({ workspaceSessionReady: false })
await render()
store.getState().setEditorDraft(file.id, 'last')
await flush()
expect(mocks.hashes.calls).toBe(0)
expect(mocks.publish).not.toHaveBeenCalled()
store.setState({ workspaceSessionReady: true })
await render()
await act(async () => releasePublication?.())
await flush()
expect(mocks.publish).toHaveBeenCalledTimes(1)
expect(latestVersion()).toBe(stableHashString('last'))
})
it('retains preview-source admission, meaningful clear, and Markdown eligibility changes', async () => {
const source = { ...file, id: 'source', language: 'typescript', filePath: '/repo/source.ts' }
const preview = {
...file,
id: 'preview',
mode: 'markdown-preview' as const,
markdownPreviewSourceFileId: source.id
}
store.setState({ openFiles: [source, preview], editorDrafts: { source: 'preview exact' } })
await render()
await flush()
expect(latestVersion()).toBe(stableHashString('preview exact'))
resetCounts()
store.getState().clearEditorDraft(source.id)
await flush()
expect(latestVersion()).toBe('file:source')
expect(mocks.hashes.calls).toBe(0)
store.setState({ openFiles: [source], editorDrafts: { source: 'ordinary source' } })
await flush()
resetCounts()
store.getState().setEditorDraft(source.id, 'ordinary edit')
await flush()
expect(mocks.publish).not.toHaveBeenCalled()
expect(mocks.hashes.calls).toBe(0)
store.setState({ openFiles: [{ ...source, language: 'markdown' }] })
await flush()
expect(latestVersion()).toBe(stableHashString('ordinary edit'))
})
})
@@ -16,7 +16,6 @@ vi.mock('./sync-runtime-graph/editor-draft-hash', async (importOriginal) => {
}
})
const { stableHashString } = await import('./sync-runtime-graph/editor-draft-hash')
const {
AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS: AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS,
buildRuntimeMobileAgentStatusProjectionForTests,
@@ -39,14 +38,6 @@ function mainAgentKey(mainAgent: MainAgentStatus) {
return mainAgent ? [mainAgent.state, mainAgent.outcome ?? null, mainAgent.stateStartedAt] : null
}
function referenceEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)])
)
)
}
function referenceOpenFilesProjection(openFiles: AppState['openFiles']): string {
return JSON.stringify(
openFiles.map((file) => ({
@@ -304,65 +295,42 @@ describe('editor draft projection on the typing path', () => {
expect(draftHashCounter.calls).toBe(0)
const markdown = { ...state, openFiles: [makeOpenFile(0, { language: 'markdown' })] }
const markdownKey = getRuntimeMobileSessionSyncKey(markdown, state, sourceKey, false)
expect(markdownKey.editorDraftsProjection).not.toBe(sourceKey.editorDraftsProjection)
expect(draftHashCounter.calls).toBe(1)
expect([...markdownKey.editorDraftsProjection]).toEqual([['file-0', 'draft']])
expect(runtimeMobileSessionSyncKeysEqual(markdownKey, sourceKey)).toBe(false)
expect(draftHashCounter.calls).toBe(0)
state = { ...markdown, editorDrafts: { 'file-0': 'edited draft' } }
const editedKey = getRuntimeMobileSessionSyncKey(state, markdown, markdownKey, false)
expect(editedKey.editorDraftsProjection).not.toBe(markdownKey.editorDraftsProjection)
expect(draftHashCounter.calls).toBe(2)
expect([...editedKey.editorDraftsProjection]).toEqual([['file-0', 'edited draft']])
expect(draftHashCounter.calls).toBe(0)
const clean = { ...state, editorDrafts: {} }
const cleanKey = getRuntimeMobileSessionSyncKey(clean, state, editedKey, false)
expect(cleanKey.editorDraftsProjection).toBe('{}')
expect(cleanKey.editorDraftsProjection.size).toBe(0)
expect(
buildMobileSessionTabSnapshots(clean, false).flatMap((snapshot) => snapshot.tabs)
).toContainEqual(expect.objectContaining({ id: 'file-0', documentVersion: 'file:file-0' }))
const closed = { ...state, openFiles: [] }
const closedKey = getRuntimeMobileSessionSyncKey(closed, state, editedKey, false)
expect(closedKey.editorDraftsProjection).toBe('{}')
expect(closedKey.editorDraftsProjection.size).toBe(0)
expect(getEditorDraftVersionByFileId(closed.editorDrafts, closed.openFiles).size).toBe(0)
expect(draftHashCounter.calls).toBe(2)
expect(draftHashCounter.calls).toBe(0)
})
it('hashes only the edited file per keystroke, not every open dirty file', () => {
it('hashes the final edited draft at snapshot capture after hash-free typing', () => {
const typedCharacters = 100
const totalDraftChars = DRAFT_FILE_COUNT * DRAFT_CHARS_PER_FILE
// Baseline: the pre-change uncached projection, driven by the same counter.
let drafts = makeDrafts()
referenceEditorDraftsProjection(drafts)
draftHashCounter.calls = 0
draftHashCounter.chars = 0
for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) {
drafts = { ...drafts, 'file-0': `${drafts['file-0']}a` }
referenceEditorDraftsProjection(drafts)
}
const before = { calls: draftHashCounter.calls, chars: draftHashCounter.chars }
resetRuntimeMobileSyncProjectionCachesForTests()
let memoDrafts = makeDrafts()
const openFiles = Array.from({ length: DRAFT_FILE_COUNT }, (_, index) =>
makeOpenFile(index, { language: 'markdown' })
)
buildRuntimeMobileEditorDraftsProjection(memoDrafts, openFiles)
getEditorDraftVersionByFileId(memoDrafts, openFiles)
draftHashCounter.calls = 0
draftHashCounter.chars = 0
for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) {
memoDrafts = { ...memoDrafts, 'file-0': `${memoDrafts['file-0']}a` }
buildRuntimeMobileEditorDraftsProjection(memoDrafts, openFiles)
}
const after = { calls: draftHashCounter.calls, chars: draftHashCounter.chars }
// Keystroke k has grown file-0 by k characters, so the exact totals are closed form.
const growth = (typedCharacters * (typedCharacters + 1)) / 2
expect(before).toEqual({
calls: typedCharacters * DRAFT_FILE_COUNT,
chars: typedCharacters * totalDraftChars + growth
})
expect(after).toEqual({
calls: typedCharacters,
chars: typedCharacters * DRAFT_CHARS_PER_FILE + growth
})
expect(before.chars / after.chars).toBeGreaterThan(DRAFT_FILE_COUNT - 0.1)
expect(draftHashCounter).toEqual({ calls: 0, chars: 0 })
getEditorDraftVersionByFileId(memoDrafts, openFiles)
expect(draftHashCounter).toEqual({ calls: 1, chars: DRAFT_CHARS_PER_FILE + typedCharacters })
})
})
@@ -1,4 +1,3 @@
// Share FNV-1a draft stamps across mobile keys/snapshots. Cost scales with text length; memoize per file or record spreads rehash every draft.
export function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
@@ -140,6 +140,24 @@ export function getRuntimeMobileSessionSyncKey(
}
}
function editorDraftsEqual(
a: ReadonlyMap<string, string>,
b: ReadonlyMap<string, string>
): boolean {
if (a === b) {
return true
}
if (a.size !== b.size) {
return false
}
for (const [fileId, content] of a) {
if (b.get(fileId) !== content) {
return false
}
}
return true
}
export function runtimeMobileSessionSyncKeysEqual(
a: RuntimeMobileSessionSyncKey,
b: RuntimeMobileSessionSyncKey
@@ -168,6 +186,6 @@ export function runtimeMobileSessionSyncKeysEqual(
a.tabsProjection === b.tabsProjection &&
a.openFilesProjection === b.openFilesProjection &&
a.browserProjection === b.browserProjection &&
a.editorDraftsProjection === b.editorDraftsProjection
editorDraftsEqual(a.editorDraftsProjection, b.editorDraftsProjection)
)
}
@@ -220,7 +220,31 @@ export function buildRuntimeMobileBrowserProjection(state: AppState): string {
)},"pagesByWorkspace":${buildBrowserPagesProjection(getBrowserPagesByWorkspace(state))}}`
}
// Monaco re-spreads drafts per keystroke; memoize each file’s unchanged string so typing does not rehash all dirty files.
let cachedDraftSourceFileIds: {
source: AppState['openFiles']
ids: ReadonlySet<string>
} | null = null
function getEditorDraftSourceFileIds(openFiles: AppState['openFiles']): ReadonlySet<string> {
if (cachedDraftSourceFileIds?.source === openFiles) {
return cachedDraftSourceFileIds.ids
}
const indexes = getOpenFileIndexes(openFiles)
const ids = new Set<string>()
for (const file of openFiles) {
if (!isMobilePublishableOpenFile(file)) {
continue
}
const source = getMobileMarkdownSourceFile(file, indexes.byWorktreeAndId.get(file.worktreeId))
if (source) {
ids.add(source.id)
}
}
cachedDraftSourceFileIds = { source: openFiles, ids }
return ids
}
// Reuse unchanged file hashes between coalesced snapshot captures.
function getEditorDraftHashCache(
editorDrafts: AppState['editorDrafts'],
openFiles: AppState['openFiles']
@@ -230,26 +254,11 @@ function getEditorDraftHashCache(
return cached
}
let sourceFileIds = cached?.openFiles === openFiles ? cached.sourceFileIds : undefined
if (!sourceFileIds) {
const indexes = getOpenFileIndexes(openFiles)
const ids = new Set<string>()
for (const file of openFiles) {
if (!isMobilePublishableOpenFile(file)) {
continue
}
const source = getMobileMarkdownSourceFile(file, indexes.byWorktreeAndId.get(file.worktreeId))
if (source) {
ids.add(source.id)
}
}
sourceFileIds = ids
}
const sourceFileIds = getEditorDraftSourceFileIds(openFiles)
const previousEntries = cached?.entries
const entries = new Map<string, EditorDraftHashCacheEntry>()
const hashByFileId = new Map<string, string>()
const parts: string[] = []
for (const fileId of sourceFileIds) {
const content = editorDrafts[fileId]
if (content === undefined) {
@@ -260,21 +269,17 @@ function getEditorDraftHashCache(
if (previous?.content === content) {
entry = previous
} else {
const fileIdJson = previous?.fileIdJson ?? JSON.stringify(fileId)
const hash = stableHashString(content)
entry = { content, hash, fileIdJson, projection: `${fileIdJson}:${JSON.stringify(hash)}` }
entry = { content, hash }
}
entries.set(fileId, entry)
hashByFileId.set(fileId, entry.hash)
parts.push(entry.projection)
}
const next: EditorDraftHashCache = {
source: editorDrafts,
openFiles,
sourceFileIds,
entries,
hashByFileId,
projection: `{${parts.join(',')}}`
hashByFileId
}
graphState.cachedEditorDraftHashes = next
return next
@@ -283,11 +288,18 @@ function getEditorDraftHashCache(
export function buildRuntimeMobileEditorDraftsProjection(
editorDrafts: AppState['editorDrafts'],
openFiles: AppState['openFiles']
): string {
return getEditorDraftHashCache(editorDrafts, openFiles).projection
): ReadonlyMap<string, string> {
const drafts = new Map<string, string>()
for (const fileId of getEditorDraftSourceFileIds(openFiles)) {
const content = editorDrafts[fileId]
if (content !== undefined) {
drafts.set(fileId, content)
}
}
return drafts
}
/** Per-file draft version stamps for the mobile session snapshot; shares the keystroke memo. */
/** Per-file version stamps are hashed only when a mobile snapshot is built. */
export function getEditorDraftVersionByFileId(
editorDrafts: AppState['editorDrafts'],
openFiles: AppState['openFiles']
@@ -301,4 +313,5 @@ export function resetRuntimeMobileSyncProjectionCachesForTests(): void {
graphState.cachedBrowserWorkspacesProjection = null
graphState.cachedBrowserPagesProjection = null
graphState.cachedEditorDraftHashes = null
cachedDraftSourceFileIds = null
}
@@ -28,7 +28,7 @@ export type RuntimeMobileSessionSyncKey = {
tabsProjection: string
openFilesProjection: string
browserProjection: string
editorDraftsProjection: string
editorDraftsProjection: ReadonlyMap<string, string>
}
export type OpenFileByWorktreeAndId = Map<string, Map<string, AppState['openFiles'][number]>>
@@ -79,20 +79,16 @@ export type BrowserPagesProjectionCache = {
entries: Map<string, BrowserPagesProjectionCacheEntry>
projection: string
}
/** One dirty file's FNV draft stamp plus its pre-serialized projection fragment. */
/** One dirty file's content and last published draft stamp. */
export type EditorDraftHashCacheEntry = {
content: string
hash: string
fileIdJson: string
projection: string
}
export type EditorDraftHashCache = {
source: AppState['editorDrafts']
openFiles: AppState['openFiles']
sourceFileIds: ReadonlySet<string>
entries: Map<string, EditorDraftHashCacheEntry>
hashByFileId: Map<string, string>
projection: string
}
export type MobileSessionAgentStatusByWorktree = ReadonlyMap<
string,