diff --git a/src/renderer/src/app-shell/use-runtime-graph-sync-draft-hashing.test.tsx b/src/renderer/src/app-shell/use-runtime-graph-sync-draft-hashing.test.tsx new file mode 100644 index 00000000000..be31a2516e2 --- /dev/null +++ b/src/renderer/src/app-shell/use-runtime-graph-sync-draft-hashing.test.tsx @@ -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>(), + 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 + let snapshots: RuntimeMobileSessionTabsSnapshot[][] + let revision = 0 + let releasePublication: (() => void) | undefined + + async function render(): Promise { + revision += 1 + await act(async () => root.render()) + } + async function flush(): Promise { + 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((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')) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index 288b025c041..dbd55c145cf 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -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 }) }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts index 82b6a3e4530..5f7c70b564d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts @@ -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) { diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts index 97cbd126504..799ba2a80d5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts @@ -140,6 +140,24 @@ export function getRuntimeMobileSessionSyncKey( } } +function editorDraftsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): 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) ) } diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 60669665953..dfcc58289d1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -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 +} | null = null + +function getEditorDraftSourceFileIds(openFiles: AppState['openFiles']): ReadonlySet { + if (cachedDraftSourceFileIds?.source === openFiles) { + return cachedDraftSourceFileIds.ids + } + const indexes = getOpenFileIndexes(openFiles) + const ids = new Set() + 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() - 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() const hashByFileId = new Map() - 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 { + const drafts = new Map() + 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 } diff --git a/src/renderer/src/runtime/sync-runtime-graph/types.ts b/src/renderer/src/runtime/sync-runtime-graph/types.ts index e3e190d3a58..29d85248dcc 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/types.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/types.ts @@ -28,7 +28,7 @@ export type RuntimeMobileSessionSyncKey = { tabsProjection: string openFilesProjection: string browserProjection: string - editorDraftsProjection: string + editorDraftsProjection: ReadonlyMap } export type OpenFileByWorktreeAndId = Map> @@ -79,20 +79,16 @@ export type BrowserPagesProjectionCache = { entries: Map 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 entries: Map hashByFileId: Map - projection: string } export type MobileSessionAgentStatusByWorktree = ReadonlyMap< string,