From 2121db7aadedc6b4194880a30aa0f5b5b5171b1a Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 4 Aug 2026 13:46:49 -0700 Subject: [PATCH 1/6] smol cache fix --- packages/diffs/src/utils/parseDiffFromFile.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/utils/parseDiffFromFile.ts b/packages/diffs/src/utils/parseDiffFromFile.ts index d7aa6e05e..c13c27f1e 100644 --- a/packages/diffs/src/utils/parseDiffFromFile.ts +++ b/packages/diffs/src/utils/parseDiffFromFile.ts @@ -37,12 +37,15 @@ export function parseDiffFromFile( const fileData = processFile(patch, { cacheKey: (() => { - const oldCacheKey = oldFile?.cacheKey ?? oldFile?.name; - const newCacheKey = newFile?.cacheKey ?? newFile?.name; + const oldCacheKey = oldFile?.cacheKey; + const newCacheKey = newFile?.cacheKey; if (oldCacheKey != null && newCacheKey != null) { return oldCacheKey + ':' + newCacheKey; } - return oldCacheKey ?? newCacheKey; + if (oldCacheKey != null || newCacheKey != null) { + return `diff:${oldCacheKey ?? newCacheKey}`; + } + return undefined; })(), oldFile: resolvedOldFile, newFile: resolvedNewFile, From 28ae7c10bec8bc2598c4a039b2001973cbef858a Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 4 Aug 2026 14:20:55 -0700 Subject: [PATCH 2/6] Phase 1: fix file name derived cacheKeys, it's an inherently broken system --- packages/diffs/src/components/FileDiff.ts | 20 ++++--- .../test/FileDiff.rerenderInputs.test.ts | 49 ++++++++++++++++- packages/diffs/test/WorkerPoolManager.test.ts | 46 ++++++++++++++++ packages/diffs/test/parseDiffFromFile.test.ts | 14 ++--- .../test/virtualizedEditorViewport.test.ts | 54 +++++++++++++++++++ 5 files changed, 166 insertions(+), 17 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 294926785..1739007c3 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1000,14 +1000,6 @@ export class FileDiff< ); } - // use the file name as the cache key if it is not set - if (fileDiff != null && fileDiff.cacheKey === undefined) { - fileDiff.cacheKey = - fileDiff.prevName != null - ? fileDiff.prevName + ':' + fileDiff.name - : fileDiff.name; - } - // postpone background tokenizing to next frame for avoiding UI freeze // during render this.editor?.__postponeBgTokenizeToNextFrame(); @@ -1024,6 +1016,18 @@ export class FileDiff< hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || !areOptionalFilesEqual(newFile, this.additionFile)); + const { fileDiffCache: sessionDiff } = this; + if ( + fileDiff != null && + this.editor != null && + sessionDiff?.editSessionDirty === true && + fileDiff.cacheKey === sessionDiff.cacheKey + ) { + // Host rerenders may rebuild metadata from stale props while the editor + // owns newer contents. Keep the dirty session authoritative without + // manufacturing a cache identity for unkeyed diffs. + fileDiff = sessionDiff; + } let diffDidChange = fileDiff != null && fileDiff !== this.fileDiff; const annotationsChanged = lineAnnotations != null && diff --git a/packages/diffs/test/FileDiff.rerenderInputs.test.ts b/packages/diffs/test/FileDiff.rerenderInputs.test.ts index e65f93e26..1d6b04ab3 100644 --- a/packages/diffs/test/FileDiff.rerenderInputs.test.ts +++ b/packages/diffs/test/FileDiff.rerenderInputs.test.ts @@ -1,7 +1,7 @@ import { afterAll, expect, test } from 'bun:test'; -import { disposeHighlighter, FileDiff } from '../src'; -import { installDom, wait } from './domHarness'; +import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; +import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { await disposeHighlighter(); @@ -72,3 +72,48 @@ test('a host render after an internal rerender takes the early-return path', asy cleanup(); } }); + +test('parsed unkeyed diffs with the same filename render fresh contents', async () => { + const { cleanup } = installDom(); + let instance: FileDiff | undefined; + try { + const firstDiff = parseDiffFromFile( + { name: 'same.ts', contents: 'const base = 0;\n' }, + { name: 'same.ts', contents: 'const firstMarker = 1;\n' } + ); + const secondDiff = parseDiffFromFile( + { name: 'same.ts', contents: 'const base = 0;\n' }, + { name: 'same.ts', contents: 'const secondMarker = 2;\n' } + ); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + instance = new FileDiff({ + disableFileHeader: true, + diffStyle: 'unified', + }); + + expect(firstDiff.cacheKey).toBeUndefined(); + expect(secondDiff.cacheKey).toBeUndefined(); + + instance.render({ fileDiff: firstDiff, fileContainer }); + await waitFor( + () => + fileContainer.shadowRoot?.textContent?.includes('firstMarker') === true + ); + expect(fileContainer.shadowRoot?.textContent).toContain('firstMarker'); + + instance.render({ fileDiff: secondDiff, fileContainer }); + await waitFor( + () => + fileContainer.shadowRoot?.textContent?.includes('secondMarker') === true + ); + + expect(fileContainer.shadowRoot?.textContent).toContain('secondMarker'); + expect(fileContainer.shadowRoot?.textContent).not.toContain('firstMarker'); + expect(firstDiff.cacheKey).toBeUndefined(); + expect(secondDiff.cacheKey).toBeUndefined(); + } finally { + instance?.cleanUp(); + cleanup(); + } +}); diff --git a/packages/diffs/test/WorkerPoolManager.test.ts b/packages/diffs/test/WorkerPoolManager.test.ts index 892373cf2..590484448 100644 --- a/packages/diffs/test/WorkerPoolManager.test.ts +++ b/packages/diffs/test/WorkerPoolManager.test.ts @@ -65,6 +65,52 @@ describe('WorkerPoolManager lifecycle', () => { }); describe('WorkerPoolManager cache priming', () => { + test('does not read or populate the shared cache for an unkeyed diff', async () => { + const { manager, worker } = await createInitializedManager(); + const successes: FileDiffMetadata[] = []; + const instance: DiffRendererInstance = { + __id: 'unkeyed-diff-renderer', + onHighlightSuccess(diff) { + successes.push(diff); + }, + onHighlightError(error) { + throw error; + }, + }; + const diff = parseDiffFromFile( + { name: 'file.ts', contents: 'const value = "old";\n' }, + { name: 'file.ts', contents: 'const value = "new";\n' } + ); + const sentinel = { + result: { + code: { additionLines: [], deletionLines: [] }, + themeStyles: 'sentinel', + baseThemeType: undefined, + }, + options: manager.getDiffRenderOptions(), + }; + + try { + expect(diff.cacheKey).toBeUndefined(); + manager.inspectCaches().diffCache.set(diff.name, sentinel); + expect(manager.getDiffResultCache(diff)).toBeUndefined(); + + manager.highlightDiffAST(instance, diff); + const request = await worker.waitForDiffRequest(); + expect(request.diff.cacheKey).toBeUndefined(); + + respondToDiffRequest(manager, worker, request); + + expect(successes).toEqual([diff]); + expect(manager.inspectCaches().diffCache.size).toBe(1); + expect(manager.inspectCaches().diffCache.get(diff.name)).toBe(sentinel); + expect(manager.getDiffResultCache(diff)).toBeUndefined(); + } finally { + manager.cleanUpTasks(instance); + manager.terminate(); + } + }); + test('primeDiffHighlightCache resolves after a successful response populates the diff cache', async () => { const { manager, worker } = await createInitializedManager(); try { diff --git a/packages/diffs/test/parseDiffFromFile.test.ts b/packages/diffs/test/parseDiffFromFile.test.ts index b083eecbe..4ad4a0a1f 100644 --- a/packages/diffs/test/parseDiffFromFile.test.ts +++ b/packages/diffs/test/parseDiffFromFile.test.ts @@ -14,7 +14,7 @@ describe('parseDiffFromFile', () => { test('should parse diff from fileOld and fileNew and match its digest', () => { expect(result.hunks.length).toBeGreaterThan(0); - expect(result.cacheKey).toBe('fileOld.txt:fileNew.txt'); + expect(result.cacheKey).toBeUndefined(); // Compact geometry lock; line-level accuracy is covered by the invariant // test below and the renderer's content tests expect(hunkDigest(result)).toMatchSnapshot('parsed diff digest'); @@ -68,7 +68,7 @@ describe('parseDiffFromFile', () => { const result = parseDiffFromFile(oldFile, newFile); expect(result.type).toBe('change'); - expect(result.cacheKey).toBe('test.txt:test.txt'); + expect(result.cacheKey).toBeUndefined(); }); test('should have type "change" (default) when empty files did not change', () => { @@ -83,7 +83,7 @@ describe('parseDiffFromFile', () => { const result = parseDiffFromFile(oldFile, newFile); expect(result.type).toBe('change'); - expect(result.cacheKey).toBe('test.txt:test.txt'); + expect(result.cacheKey).toBeUndefined(); }); test('uses file cacheKeys when both sides provide them', () => { @@ -103,13 +103,13 @@ describe('parseDiffFromFile', () => { expect(result.cacheKey).toBe('old-cache:new-cache'); }); - test('falls back to file names when cacheKeys are omitted', () => { + test('leaves cacheKey unset when file cacheKeys are omitted', () => { const result = parseDiffFromFile( { name: 'old-name.txt', contents: 'old\n' }, { name: 'new-name.txt', contents: 'new\n' } ); - expect(result.cacheKey).toBe('old-name.txt:new-name.txt'); + expect(result.cacheKey).toBeUndefined(); }); test('parses a new file from a missing old side', () => { @@ -129,7 +129,7 @@ describe('parseDiffFromFile', () => { expect(result.isPartial).toBe(false); expect(result.deletionLines).toEqual([]); expect(result.additionLines).toEqual(splitFileContents(newFile.contents)); - expect(result.cacheKey).toBe('created-cache'); + expect(result.cacheKey).toBe('diff:created-cache'); expect(verifyHunkLineValues(result)).toEqual([]); }); @@ -150,7 +150,7 @@ describe('parseDiffFromFile', () => { expect(result.isPartial).toBe(false); expect(result.deletionLines).toEqual(splitFileContents(oldFile.contents)); expect(result.additionLines).toEqual([]); - expect(result.cacheKey).toBe('deleted-cache'); + expect(result.cacheKey).toBe('diff:deleted-cache'); expect(verifyHunkLineValues(result)).toEqual([]); }); diff --git a/packages/diffs/test/virtualizedEditorViewport.test.ts b/packages/diffs/test/virtualizedEditorViewport.test.ts index c89c6cec6..fcfb55da3 100644 --- a/packages/diffs/test/virtualizedEditorViewport.test.ts +++ b/packages/diffs/test/virtualizedEditorViewport.test.ts @@ -80,6 +80,60 @@ async function renderFileDiff( } describe('virtualized editor viewport', () => { + test('renders fresh parsed contents without deriving a filename cache key', async () => { + const dom = installDom(); + const root = document.createElement('div'); + const virtualizer = createSimpleVirtualizer(root); + const fileContainer = document.createElement('div'); + root.appendChild(fileContainer); + const fileDiff = new VirtualizedFileDiff( + { + diffStyle: 'unified', + disableFileHeader: true, + theme: DEFAULT_THEMES, + }, + virtualizer + ); + const firstDiff = parseDiffFromFile( + { name: 'same.txt', contents: 'base\n', lang: 'text' }, + { name: 'same.txt', contents: 'first marker\n', lang: 'text' } + ); + const secondDiff = parseDiffFromFile( + { name: 'same.txt', contents: 'base\n', lang: 'text' }, + { name: 'same.txt', contents: 'second marker\n', lang: 'text' } + ); + + try { + expect(firstDiff.cacheKey).toBeUndefined(); + expect(secondDiff.cacheKey).toBeUndefined(); + + fileDiff.render({ fileDiff: firstDiff, fileContainer }); + await waitFor( + () => + fileContainer.shadowRoot?.textContent?.includes('first marker') === + true + ); + expect(fileContainer.shadowRoot?.textContent).toContain('first marker'); + + fileDiff.render({ fileDiff: secondDiff, fileContainer }); + await waitFor( + () => + fileContainer.shadowRoot?.textContent?.includes('second marker') === + true + ); + + expect(fileContainer.shadowRoot?.textContent).toContain('second marker'); + expect(fileContainer.shadowRoot?.textContent).not.toContain( + 'first marker' + ); + expect(firstDiff.cacheKey).toBeUndefined(); + expect(secondDiff.cacheKey).toBeUndefined(); + } finally { + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + test('uses the simple Virtualizer root', () => { const dom = installDom(); const root = document.createElement('div'); From b4a88ecbd507fea72d95e3ee1b98eef024c74a48 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 4 Aug 2026 15:27:48 -0700 Subject: [PATCH 3/6] Phase 2: only generate a cache key if both old/new have cache key otherwise you can get into bad scenarios where you combine 1 file with other files that don't have cache keys and you're fucked cause the cache keys will match but be incorrect --- packages/diffs/src/utils/parseDiffFromFile.ts | 12 ++--- .../diffs/test/diffAcceptRejectHunk.test.ts | 4 +- packages/diffs/test/parseDiffFromFile.test.ts | 49 +++++++++++++++++-- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/diffs/src/utils/parseDiffFromFile.ts b/packages/diffs/src/utils/parseDiffFromFile.ts index c13c27f1e..21befab5b 100644 --- a/packages/diffs/src/utils/parseDiffFromFile.ts +++ b/packages/diffs/src/utils/parseDiffFromFile.ts @@ -9,7 +9,7 @@ const MISSING_FILE_NAME = '/dev/null'; * Parses a diff from two file contents objects. * * If both `oldFile` and `newFile` have a `cacheKey`, the resulting diff will - * automatically get a combined cache key in the format `oldKey:newKey`. + * automatically get a combined cache key in the format `diff:oldKey:newKey`. */ export function parseDiffFromFile( oldFile: FileContents | null, @@ -39,13 +39,9 @@ export function parseDiffFromFile( cacheKey: (() => { const oldCacheKey = oldFile?.cacheKey; const newCacheKey = newFile?.cacheKey; - if (oldCacheKey != null && newCacheKey != null) { - return oldCacheKey + ':' + newCacheKey; - } - if (oldCacheKey != null || newCacheKey != null) { - return `diff:${oldCacheKey ?? newCacheKey}`; - } - return undefined; + return oldCacheKey != null && newCacheKey != null + ? `diff:${oldCacheKey}:${newCacheKey}` + : undefined; })(), oldFile: resolvedOldFile, newFile: resolvedNewFile, diff --git a/packages/diffs/test/diffAcceptRejectHunk.test.ts b/packages/diffs/test/diffAcceptRejectHunk.test.ts index 2ead51c3f..4ff65c9eb 100644 --- a/packages/diffs/test/diffAcceptRejectHunk.test.ts +++ b/packages/diffs/test/diffAcceptRejectHunk.test.ts @@ -475,7 +475,7 @@ describe('diffAcceptRejectHunk', () => { const result = diffAcceptRejectHunk(diff, 0, 'both'); - expect(result.cacheKey).toBe('old-key:new-key:b-0:0-0'); + expect(result.cacheKey).toBe('diff:old-key:new-key:b-0:0-0'); }); test('accept resolves a partial patch without materializing omitted context', () => { @@ -560,7 +560,7 @@ describe('diffAcceptRejectHunk', () => { changeIndex: 1, }); - expect(result.cacheKey).toBe('old-key:new-key:a-2:1-1'); + expect(result.cacheKey).toBe('diff:old-key:new-key:a-2:1-1'); }); test('both should inherit noEOFCR from additions', () => { diff --git a/packages/diffs/test/parseDiffFromFile.test.ts b/packages/diffs/test/parseDiffFromFile.test.ts index 4ad4a0a1f..931ea3a98 100644 --- a/packages/diffs/test/parseDiffFromFile.test.ts +++ b/packages/diffs/test/parseDiffFromFile.test.ts @@ -100,7 +100,41 @@ describe('parseDiffFromFile', () => { } ); - expect(result.cacheKey).toBe('old-cache:new-cache'); + expect(result.cacheKey).toBe('diff:old-cache:new-cache'); + }); + + test('leaves cacheKey unset when either side of a two-sided diff is unkeyed', () => { + const keyedOldFile: FileContents = { + name: 'test.txt', + contents: 'old\n', + cacheKey: 'old-cache', + }; + const keyedNewFile: FileContents = { + name: 'test.txt', + contents: 'new\n', + cacheKey: 'new-cache', + }; + + const keyedOldResults = [ + parseDiffFromFile(keyedOldFile, { + name: 'test.txt', + contents: 'first unkeyed version\n', + }), + parseDiffFromFile(keyedOldFile, { + name: 'test.txt', + contents: 'second unkeyed version\n', + }), + ]; + const keyedNewResult = parseDiffFromFile( + { name: 'test.txt', contents: 'unkeyed old version\n' }, + keyedNewFile + ); + + expect(keyedOldResults.map((result) => result.cacheKey)).toEqual([ + undefined, + undefined, + ]); + expect(keyedNewResult.cacheKey).toBeUndefined(); }); test('leaves cacheKey unset when file cacheKeys are omitted', () => { @@ -129,7 +163,7 @@ describe('parseDiffFromFile', () => { expect(result.isPartial).toBe(false); expect(result.deletionLines).toEqual([]); expect(result.additionLines).toEqual(splitFileContents(newFile.contents)); - expect(result.cacheKey).toBe('diff:created-cache'); + expect(result.cacheKey).toBeUndefined(); expect(verifyHunkLineValues(result)).toEqual([]); }); @@ -150,7 +184,7 @@ describe('parseDiffFromFile', () => { expect(result.isPartial).toBe(false); expect(result.deletionLines).toEqual(splitFileContents(oldFile.contents)); expect(result.additionLines).toEqual([]); - expect(result.cacheKey).toBe('diff:deleted-cache'); + expect(result.cacheKey).toBeUndefined(); expect(verifyHunkLineValues(result)).toEqual([]); }); @@ -160,8 +194,13 @@ describe('parseDiffFromFile', () => { contents: '', }; - expect(parseDiffFromFile(null, emptyFile).type).toBe('new'); - expect(parseDiffFromFile(emptyFile, null).type).toBe('deleted'); + const added = parseDiffFromFile(null, emptyFile); + const deleted = parseDiffFromFile(emptyFile, null); + + expect(added.type).toBe('new'); + expect(added.cacheKey).toBeUndefined(); + expect(deleted.type).toBe('deleted'); + expect(deleted.cacheKey).toBeUndefined(); }); test('throws when both file sides are missing', () => { From 983041e3061dd3e7e87a3ff9baabc6ea0d2f12f9 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 4 Aug 2026 17:35:17 -0700 Subject: [PATCH 4/6] Phase 5: stop generating TreeApp cache keys from file paths --- .../app/(trees)/_components/DemoTreeApp.tsx | 15 ++----------- apps/docs/app/(trees)/_components/TreeApp.tsx | 20 ++++++++++-------- apps/docs/test/tree-app-cache-key.test.mjs | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 apps/docs/test/tree-app-cache-key.test.mjs diff --git a/apps/docs/app/(trees)/_components/DemoTreeApp.tsx b/apps/docs/app/(trees)/_components/DemoTreeApp.tsx index 21a1b63e1..27bc5e3d8 100644 --- a/apps/docs/app/(trees)/_components/DemoTreeApp.tsx +++ b/apps/docs/app/(trees)/_components/DemoTreeApp.tsx @@ -1,4 +1,3 @@ -import type { FileContents } from '@pierre/diffs'; import { preloadFile } from '@pierre/diffs/ssr'; import { FILE_TREE_DENSITY_PRESETS } from '@pierre/trees'; import { preloadFileTree } from '@pierre/trees/ssr'; @@ -31,16 +30,6 @@ const TREE_APP_LIGHT_FILE_OPTIONS = { themeType: 'light', } as const; -// Initial paths are unique and survive moves because every file remap spreads -// the existing value. Use them as stable editor identities for this demo. -const TREE_APP_EDITOR_FILES: Readonly> = - Object.fromEntries( - Object.entries(TREE_APP_DEMO_FILES).map( - ([path, file]) => - [path, { ...file, cacheKey: file.cacheKey ?? path }] as const - ) - ); - export async function DemoTreeApp() { const treePreloadedData = preloadFileTree({ dragAndDrop: true, @@ -65,7 +54,7 @@ export async function DemoTreeApp() { // fall back to an on-the-fly highlighter pass. Each file produces two // results, so we run them all in a single Promise.all to minimize latency. const preloadedEntries = await Promise.all( - Object.entries(TREE_APP_EDITOR_FILES).map(async ([path, file]) => { + Object.entries(TREE_APP_DEMO_FILES).map(async ([path, file]) => { const [darkResult, lightResult] = await Promise.all([ preloadFile({ file, options: TREE_APP_DARK_FILE_OPTIONS }), preloadFile({ file, options: TREE_APP_LIGHT_FILE_OPTIONS }), @@ -92,7 +81,7 @@ export async function DemoTreeApp() { return ( { // Editor side: files keyed by their tree path. Mirrors the // preloadedDataById pattern already used by tree demos. Both the prerendered // HTML map and the file options may be scoped per theme so the active File - // picks up the right syntax-highlight colors when the theme toggles. Give - // files unique, rename-stable cacheKeys so render caches follow moves; - // without one, TreeApp uses the current path. + // picks up the right syntax-highlight colors when the theme toggles. Omitted + // cacheKeys disable shared render caching. A supplied key must change when + // file contents or other render-affecting identity changes; TreeApp never + // derives one from the file path. files?: Readonly>; prerenderedHTMLByPath?: TreeAppThemeValue>>; fileOptions?: TreeAppThemeValue>; // Fired on Cmd/Ctrl+S after TreeApp clears the tab's unsaved indicator. // Hosts that own the `files` map should update it here so the next edit - // cycle compares against the saved contents. + // cycle compares against the saved contents, advancing any explicit + // cacheKey according to the host's versioning scheme. onSave?: (path: string, file: FileContents) => void; // Light/dark theming. TreeApp owns the state by default; callers can observe @@ -1661,14 +1663,14 @@ export function TreeApp({ activePath != null && usesLocalFile ? (editedFilesByPath[activePath] ?? activeHostFile) : activeHostFile; - // File names are commonly only basenames, so use the unique tree path as - // the persistence identity unless the caller supplied a stable cache key. + // Keep unkeyed caller files isolated from edit-session mutation without + // inventing a shared renderer-cache identity. const activeEditorFile = useMemo( () => - activeFile == null || activePath == null || activeFile.cacheKey != null + activeFile == null || activeFile.cacheKey != null ? activeFile - : { ...activeFile, cacheKey: activePath }, - [activeFile, activePath] + : { ...activeFile }, + [activeFile] ); // Skip stale prerendered HTML while the editor is showing local contents. const activePrerenderedHTML = diff --git a/apps/docs/test/tree-app-cache-key.test.mjs b/apps/docs/test/tree-app-cache-key.test.mjs new file mode 100644 index 000000000..0b53863f9 --- /dev/null +++ b/apps/docs/test/tree-app-cache-key.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const docsRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const componentRoot = path.join(docsRoot, 'app', '(trees)', '_components'); +const componentFiles = ['TreeApp.tsx', 'DemoTreeApp.tsx']; +const CACHE_KEY_ASSIGNMENT = /(?:\bcacheKey\s*:|\.cacheKey\s*=)/; + +describe('TreeApp cache keys', () => { + for (const filename of componentFiles) { + test(`${filename} does not generate cache keys`, () => { + const source = readFileSync(path.join(componentRoot, filename), 'utf8'); + expect(source).not.toMatch(CACHE_KEY_ASSIGNMENT); + }); + } +}); From e457b19da06a329b7b018b6eb90fe392a1c5ee0d Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 4 Aug 2026 18:38:10 -0700 Subject: [PATCH 5/6] Phase 6: make generated cache keys collision-safe --- packages/diffs/src/utils/composeCacheKey.ts | 9 +++ .../diffs/src/utils/hydratePartialDiff.ts | 3 +- packages/diffs/src/utils/parseDiffFromFile.ts | 7 +- packages/diffs/src/utils/parsePatchFiles.ts | 34 ++++++--- .../diffs/test/diffAcceptRejectHunk.test.ts | 9 ++- .../diffs/test/hydratePartialDiff.test.ts | 40 ++++++++++- packages/diffs/test/parseDiffFromFile.test.ts | 24 ++++++- packages/diffs/test/parsePatchFiles.test.ts | 71 ++++++++++++++++++- 8 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 packages/diffs/src/utils/composeCacheKey.ts diff --git a/packages/diffs/src/utils/composeCacheKey.ts b/packages/diffs/src/utils/composeCacheKey.ts new file mode 100644 index 000000000..282a60ecf --- /dev/null +++ b/packages/diffs/src/utils/composeCacheKey.ts @@ -0,0 +1,9 @@ +const CACHE_KEY_VERSION = 1; + +/** Encodes caller-controlled segments without delimiter ambiguity. */ +export function composeCacheKey( + scope: string, + ...segments: readonly string[] +): string { + return `ck${CACHE_KEY_VERSION}:${JSON.stringify([scope, ...segments])}`; +} diff --git a/packages/diffs/src/utils/hydratePartialDiff.ts b/packages/diffs/src/utils/hydratePartialDiff.ts index 83955e300..c2ef110c3 100644 --- a/packages/diffs/src/utils/hydratePartialDiff.ts +++ b/packages/diffs/src/utils/hydratePartialDiff.ts @@ -5,6 +5,7 @@ import type { Hunk, } from '../types'; import { cloneFileDiffMetadata } from './cloneFileDiffMetadata'; +import { composeCacheKey } from './composeCacheKey'; import { getHunkSideEndBoundary, getHunkSideStartBoundary, @@ -231,7 +232,7 @@ function getLoadedFileCacheKey( ): string | undefined { if (oldFile != null && newFile != null) { return oldFile.cacheKey != null && newFile.cacheKey != null - ? `${oldFile.cacheKey}:${newFile.cacheKey}` + ? composeCacheKey('hydrated-files', oldFile.cacheKey, newFile.cacheKey) : undefined; } return oldFile?.cacheKey ?? newFile?.cacheKey; diff --git a/packages/diffs/src/utils/parseDiffFromFile.ts b/packages/diffs/src/utils/parseDiffFromFile.ts index 21befab5b..ed1f62a7c 100644 --- a/packages/diffs/src/utils/parseDiffFromFile.ts +++ b/packages/diffs/src/utils/parseDiffFromFile.ts @@ -1,6 +1,7 @@ import { type CreatePatchOptionsNonabortable, createTwoFilesPatch } from 'diff'; import type { FileContents, FileDiffMetadata } from '../types'; +import { composeCacheKey } from './composeCacheKey'; import { processFile } from './parsePatchFiles'; const MISSING_FILE_NAME = '/dev/null'; @@ -8,8 +9,8 @@ const MISSING_FILE_NAME = '/dev/null'; /** * Parses a diff from two file contents objects. * - * If both `oldFile` and `newFile` have a `cacheKey`, the resulting diff will - * automatically get a combined cache key in the format `diff:oldKey:newKey`. + * If both `oldFile` and `newFile` have a `cacheKey`, the resulting diff gets a + * collision-safe key derived from both values. */ export function parseDiffFromFile( oldFile: FileContents | null, @@ -40,7 +41,7 @@ export function parseDiffFromFile( const oldCacheKey = oldFile?.cacheKey; const newCacheKey = newFile?.cacheKey; return oldCacheKey != null && newCacheKey != null - ? `diff:${oldCacheKey}:${newCacheKey}` + ? composeCacheKey('diff', oldCacheKey, newCacheKey) : undefined; })(), oldFile: resolvedOldFile, diff --git a/packages/diffs/src/utils/parsePatchFiles.ts b/packages/diffs/src/utils/parsePatchFiles.ts index da54f13f6..551e73e56 100644 --- a/packages/diffs/src/utils/parsePatchFiles.ts +++ b/packages/diffs/src/utils/parsePatchFiles.ts @@ -16,6 +16,7 @@ import type { ParsedPatch, } from '../types'; import { cleanLastNewline } from './cleanLastNewline'; +import { composeCacheKey } from './composeCacheKey'; import { detachString, releaseStringDetachBuffer } from './detachString'; import { getHunkSideEndBoundary, @@ -46,7 +47,8 @@ export function processPatch( function _processPatch( data: string, cacheKeyPrefix?: string, - throwOnError = false + throwOnError = false, + patchIndex?: number ): ParsedPatch { const isGitDiff = isGitDiffPatch(data); const rawFiles = isGitDiff @@ -92,7 +94,18 @@ function _processPatch( const currentFile = _processFile(fileOrPatchMetadata, { cacheKey: cacheKeyPrefix != null - ? `${cacheKeyPrefix}-${files.length}` + ? patchIndex == null + ? composeCacheKey( + 'patch-file', + cacheKeyPrefix, + String(files.length) + ) + : composeCacheKey( + 'patch-file', + cacheKeyPrefix, + String(patchIndex), + String(files.length) + ) : undefined, isGitDiff, throwOnError, @@ -600,9 +613,9 @@ function _processFile( * Parses a patch file string into an array of parsed patches. * * @param data - The raw patch file content (supports multi-commit patches) - * @param cacheKeyPrefix - Optional prefix for generating cache keys. When provided, - * each file in the patch will get a cache key in the format `prefix-patchIndex-fileIndex`. - * This enables caching of rendered diff results in the worker pool. + * @param cacheKeyPrefix - Optional prefix for collision-safe cache keys derived + * from the prefix, patch index, and file index. This enables caching of + * rendered diff results in the worker pool. */ export function parsePatchFiles( data: string, @@ -618,12 +631,11 @@ export function parsePatchFiles( for (const patch of rawPatches) { try { patches.push( - processPatch( + _processPatch( patch, - cacheKeyPrefix != null - ? `${cacheKeyPrefix}-${patches.length}` - : undefined, - throwOnError + cacheKeyPrefix, + throwOnError, + cacheKeyPrefix != null ? patches.length : undefined ) ); } catch (error) { @@ -632,6 +644,8 @@ export function parsePatchFiles( } else { console.error(error); } + } finally { + releaseStringDetachBuffer(); } } return patches; diff --git a/packages/diffs/test/diffAcceptRejectHunk.test.ts b/packages/diffs/test/diffAcceptRejectHunk.test.ts index 4ff65c9eb..e360697fc 100644 --- a/packages/diffs/test/diffAcceptRejectHunk.test.ts +++ b/packages/diffs/test/diffAcceptRejectHunk.test.ts @@ -5,6 +5,7 @@ import type { ContextContent, FileDiffMetadata, } from '../src/types'; +import { composeCacheKey } from '../src/utils/composeCacheKey'; import { diffAcceptRejectHunk } from '../src/utils/diffAcceptRejectHunk'; import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; import { parseMergeConflictDiffFromFile } from '../src/utils/parseMergeConflictDiffFromFile'; @@ -475,7 +476,9 @@ describe('diffAcceptRejectHunk', () => { const result = diffAcceptRejectHunk(diff, 0, 'both'); - expect(result.cacheKey).toBe('diff:old-key:new-key:b-0:0-0'); + expect(result.cacheKey).toBe( + `${composeCacheKey('diff', 'old-key', 'new-key')}:b-0:0-0` + ); }); test('accept resolves a partial patch without materializing omitted context', () => { @@ -560,7 +563,9 @@ describe('diffAcceptRejectHunk', () => { changeIndex: 1, }); - expect(result.cacheKey).toBe('diff:old-key:new-key:a-2:1-1'); + expect(result.cacheKey).toBe( + `${composeCacheKey('diff', 'old-key', 'new-key')}:a-2:1-1` + ); }); test('both should inherit noEOFCR from additions', () => { diff --git a/packages/diffs/test/hydratePartialDiff.test.ts b/packages/diffs/test/hydratePartialDiff.test.ts index 1ef72bd79..08de1bb6b 100644 --- a/packages/diffs/test/hydratePartialDiff.test.ts +++ b/packages/diffs/test/hydratePartialDiff.test.ts @@ -6,6 +6,7 @@ import type { FileDiffLoadedFiles, FileDiffMetadata, } from '../src/types'; +import { composeCacheKey } from '../src/utils/composeCacheKey'; import { hydratePartialDiff } from '../src/utils/hydratePartialDiff'; import { parsePatchFiles } from '../src/utils/parsePatchFiles'; import { splitFileContents } from '../src/utils/splitFileContents'; @@ -181,7 +182,44 @@ describe('hydratePartialDiff', () => { const hydrated = hydratePartialDiff('merge', partial, { oldFile, newFile }); expect(hydrated).toBe(partial); - expect(hydrated.cacheKey).toBe('old-full:new-full'); + expect(hydrated.cacheKey).toBe( + composeCacheKey('hydrated-files', 'old-full', 'new-full') + ); + }); + + test('encodes loaded file keys without delimiter or hydration-suffix collisions', () => { + const hydrateWithKeys = (oldCacheKey: string, newCacheKey: string) => { + const oldFile: FileContents = { + name: 'collision.txt', + cacheKey: oldCacheKey, + contents: 'old value\n', + }; + const newFile: FileContents = { + name: 'collision.txt', + cacheKey: newCacheKey, + contents: 'new value\n', + }; + const partial = parseSingleFile( + createTwoFilesPatch( + oldFile.name, + newFile.name, + oldFile.contents, + newFile.contents, + undefined, + undefined, + { context: 0 } + ) + ); + return hydratePartialDiff('merge', partial, { oldFile, newFile }) + .cacheKey; + }; + + const firstKey = hydrateWithKeys('a:b', 'c'); + const secondKey = hydrateWithKeys('a', 'b:c'); + + expect(firstKey).not.toBe(secondKey); + expect(hydrateWithKeys('a:b', 'c')).toBe(firstKey); + expect(hydrateWithKeys('a', 'b:hydrated')).not.toBe('a:b:hydrated'); }); test('does not use one loaded file cache key for a two-sided diff', () => { diff --git a/packages/diffs/test/parseDiffFromFile.test.ts b/packages/diffs/test/parseDiffFromFile.test.ts index 931ea3a98..ff3f106e6 100644 --- a/packages/diffs/test/parseDiffFromFile.test.ts +++ b/packages/diffs/test/parseDiffFromFile.test.ts @@ -100,7 +100,29 @@ describe('parseDiffFromFile', () => { } ); - expect(result.cacheKey).toBe('diff:old-cache:new-cache'); + expect(result.cacheKey).toBe('ck1:["diff","old-cache","new-cache"]'); + }); + + test('encodes caller cache key segments without delimiter collisions', () => { + const parseWithKeys = (oldCacheKey: string, newCacheKey: string) => + parseDiffFromFile( + { + name: 'test.txt', + contents: 'old\n', + cacheKey: oldCacheKey, + }, + { + name: 'test.txt', + contents: 'new\n', + cacheKey: newCacheKey, + } + ).cacheKey; + + const firstKey = parseWithKeys('a:b', 'c'); + const secondKey = parseWithKeys('a', 'b:c'); + + expect(firstKey).not.toBe(secondKey); + expect(parseWithKeys('a:b', 'c')).toBe(firstKey); }); test('leaves cacheKey unset when either side of a two-sided diff is unkeyed', () => { diff --git a/packages/diffs/test/parsePatchFiles.test.ts b/packages/diffs/test/parsePatchFiles.test.ts index 0d485eb3b..ea5e10e63 100644 --- a/packages/diffs/test/parsePatchFiles.test.ts +++ b/packages/diffs/test/parsePatchFiles.test.ts @@ -2,7 +2,12 @@ import { afterAll, describe, expect, spyOn, test } from 'bun:test'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import { DiffHunksRenderer } from '../src/renderers/DiffHunksRenderer'; -import { parsePatchFiles, processFile } from '../src/utils/parsePatchFiles'; +import { composeCacheKey } from '../src/utils/composeCacheKey'; +import { + parsePatchFiles, + processFile, + processPatch, +} from '../src/utils/parsePatchFiles'; import { diffPatch, finalBlankLinePatch, @@ -21,6 +26,18 @@ afterAll(async () => { await disposeHighlighter(); }); +function createFilePatch(name: string): string { + return [ + `diff --git a/${name} b/${name}\n`, + 'index 1111111..2222222 100644\n', + `--- a/${name}\n`, + `+++ b/${name}\n`, + '@@ -1 +1 @@\n', + '-old\n', + '+new\n', + ].join(''); +} + describe('parsePatchFiles', () => { const result = parsePatchFiles(diffPatch); test('should parse diff.patch and match its digest snapshot', () => { @@ -29,6 +46,58 @@ describe('parsePatchFiles', () => { expect(patchDigest(result)).toMatchSnapshot('git pr patch digest'); }); + test('generates deterministic keys from patch and file indexes', () => { + const prefix = 'prefix-0:1'; + const firstPatch = `${createFilePatch('one.txt')}${createFilePatch('two.txt')}`; + const patchFile = [ + 'From aaaaa first patch\n', + firstPatch, + 'From bbbbb second patch\n', + createFilePatch('three.txt'), + createFilePatch('four.txt'), + ].join(''); + const getKeys = () => + parsePatchFiles(patchFile, prefix, true).map((patch) => + patch.files.map((file) => file.cacheKey) + ); + const expectedKeys = [ + [ + composeCacheKey('patch-file', prefix, '0', '0'), + composeCacheKey('patch-file', prefix, '0', '1'), + ], + [ + composeCacheKey('patch-file', prefix, '1', '0'), + composeCacheKey('patch-file', prefix, '1', '1'), + ], + ]; + + const keys = getKeys(); + expect(keys).toEqual(expectedKeys); + expect(new Set(keys.flat()).size).toBe(4); + expect(getKeys()).toEqual(keys); + + const directPrefix = `${prefix}-0`; + const directKeys = processPatch(firstPatch, directPrefix, true).files.map( + (file) => file.cacheKey + ); + expect(directKeys).toEqual([ + composeCacheKey('patch-file', directPrefix, '0'), + composeCacheKey('patch-file', directPrefix, '1'), + ]); + expect(new Set([...keys.flat(), ...directKeys]).size).toBe(6); + }); + + test('keeps a direct processFile cache key authoritative', () => { + const cacheKey = 'caller:key-0-0:hydrated'; + const file = processFile(createFilePatch('direct.txt'), { + cacheKey, + isGitDiff: true, + throwOnError: true, + }); + + expect(file?.cacheKey).toBe(cacheKey); + }); + test('patches with a final blank line should have a \\n added', () => { const result = parsePatchFiles(finalBlankLinePatch); expect(result).toMatchSnapshot('final blank line patch'); From e7d029a7a8f12fa54ecdb18083b1fee8e6247b37 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 5 Aug 2026 15:49:46 -0700 Subject: [PATCH 6/6] PR improvements --- packages/diffs/src/components/FileDiff.ts | 13 ++- .../test/FileDiff.rerenderInputs.test.ts | 87 +++++++++++++++++++ .../test/editorPersistStateLifecycle.test.ts | 9 +- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 1739007c3..e924a229e 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1021,11 +1021,16 @@ export class FileDiff< fileDiff != null && this.editor != null && sessionDiff?.editSessionDirty === true && - fileDiff.cacheKey === sessionDiff.cacheKey + fileDiff.cacheKey === sessionDiff.cacheKey && + fileDiff.name === sessionDiff.name && + fileDiff.lang === sessionDiff.lang && + (fileDiff.cacheKey !== undefined || + fileDiff.prevName === sessionDiff.prevName) ) { - // Host rerenders may rebuild metadata from stale props while the editor - // owns newer contents. Keep the dirty session authoritative without - // manufacturing a cache identity for unkeyed diffs. + // Preserve dirty metadata only for the same editor target. Unkeyed diffs + // also compare the previous path because no cache key distinguishes it. + // This is a temporary workaround for edit vs render content change + // hardening fileDiff = sessionDiff; } let diffDidChange = fileDiff != null && fileDiff !== this.fileDiff; diff --git a/packages/diffs/test/FileDiff.rerenderInputs.test.ts b/packages/diffs/test/FileDiff.rerenderInputs.test.ts index 1d6b04ab3..c07eac145 100644 --- a/packages/diffs/test/FileDiff.rerenderInputs.test.ts +++ b/packages/diffs/test/FileDiff.rerenderInputs.test.ts @@ -1,6 +1,7 @@ import { afterAll, expect, test } from 'bun:test'; import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; +import type { DiffsEditor, FileDiffMetadata } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { @@ -32,6 +33,23 @@ async function waitForStableRow( return row; } +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + +function createDiff(name: string, marker: string): FileDiffMetadata { + return parseDiffFromFile( + { name, contents: 'const base = 0;\n' }, + { name, contents: `const ${marker} = 1;\n` } + ); +} + // FileDiff.render stored deletionFile/additionFile from the render input even // when none was passed. Internal rerenders (highlight completions, hunk // expansion) pass no input, so they wiped the stored pair, and every later @@ -117,3 +135,72 @@ test('parsed unkeyed diffs with the same filename render fresh contents', async cleanup(); } }); + +for (const targetChange of ['name', 'prevName', 'lang'] as const) { + test(`a dirty unkeyed session does not swallow a diff with a different ${targetChange}`, async () => { + const { cleanup } = installDom(); + const firstDiff = createDiff('same.ts', 'firstMarker'); + const secondDiff = createDiff('same.ts', 'secondMarker'); + if (targetChange === 'name') { + secondDiff.name = 'other.ts'; + } else if (targetChange === 'prevName') { + firstDiff.prevName = 'first-old.ts'; + secondDiff.prevName = 'second-old.ts'; + } else { + firstDiff.lang = 'typescript'; + secondDiff.lang = 'javascript'; + } + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new FileDiff({ disableFileHeader: true }); + + try { + instance.render({ fileDiff: firstDiff, fileContainer }); + await waitForStableRow(fileContainer); + const detach = instance.attachEditor(createEditorStub()); + firstDiff.editSessionDirty = true; + + instance.render({ + fileDiff: secondDiff, + fileContainer, + forceRender: true, + }); + await waitForStableRow(fileContainer); + + expect(instance.fileDiff).toBe(secondDiff); + detach(); + } finally { + instance.cleanUp(); + cleanup(); + } + }); +} + +test('a dirty unkeyed session remains authoritative for the same target', async () => { + const { cleanup } = installDom(); + const firstDiff = createDiff('same.ts', 'firstMarker'); + const secondDiff = createDiff('same.ts', 'secondMarker'); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new FileDiff({ disableFileHeader: true }); + + try { + instance.render({ fileDiff: firstDiff, fileContainer }); + await waitForStableRow(fileContainer); + const detach = instance.attachEditor(createEditorStub()); + firstDiff.editSessionDirty = true; + + instance.render({ + fileDiff: secondDiff, + fileContainer, + forceRender: true, + }); + await waitForStableRow(fileContainer); + + expect(instance.fileDiff).toBe(firstDiff); + detach(); + } finally { + instance.cleanUp(); + cleanup(); + } +}); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 9b479f52d..15a6c0da5 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -664,10 +664,15 @@ describe('Editor persisted state lifecycle', () => { viewport.scrollTop = 40; viewport.scrollLeft = 8; - const oldFile: FileContents = { name: 'diffed.ts', contents: 'alpha\n' }; + const oldFile: FileContents = { + name: 'diffed.ts', + contents: 'alpha\n', + cacheKey: 'diffed:old', + }; const newFile: FileContents = { name: 'diffed.ts', contents: 'alpha\nbravo\n', + cacheKey: 'diffed:new', }; const fileDiff = new FileDiff({ disableErrorHandling: true, @@ -727,10 +732,12 @@ describe('Editor persisted state lifecycle', () => { const oldFile: FileContents = { name: 'diffed-reparse.ts', contents: 'alpha\n', + cacheKey: 'diffed-reparse:old', }; const newFile: FileContents = { name: 'diffed-reparse.ts', contents: 'alpha\nbravo\n', + cacheKey: 'diffed-reparse:new', }; const first = new FileDiff({ disableErrorHandling: true,