Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions apps/docs/app/(trees)/_components/DemoTreeApp.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Record<string, FileContents>> =
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,
Expand All @@ -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 }),
Expand All @@ -92,7 +81,7 @@ export async function DemoTreeApp() {

return (
<DemoTreeAppClient
files={TREE_APP_EDITOR_FILES}
files={TREE_APP_DEMO_FILES}
initialActivePath={TREE_APP_DEMO_INITIAL_ACTIVE_PATH}
initialExpandedPaths={TREE_APP_DEMO_INITIAL_EXPANDED_PATHS}
paths={TREE_APP_DEMO_PATHS}
Expand Down
20 changes: 11 additions & 9 deletions apps/docs/app/(trees)/_components/TreeApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,17 @@ export interface TreeAppProps<LAnnotation = unknown> {
// 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<Record<string, FileContents>>;
prerenderedHTMLByPath?: TreeAppThemeValue<Readonly<Record<string, string>>>;
fileOptions?: TreeAppThemeValue<FileOptions<LAnnotation>>;
// 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
Expand Down Expand Up @@ -1661,14 +1663,14 @@ export function TreeApp<LAnnotation = unknown>({
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 =
Expand Down
21 changes: 21 additions & 0 deletions apps/docs/test/tree-app-cache-key.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
25 changes: 17 additions & 8 deletions packages/diffs/src/components/FileDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1024,6 +1016,23 @@ 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 &&
fileDiff.name === sessionDiff.name &&
fileDiff.lang === sessionDiff.lang &&
(fileDiff.cacheKey !== undefined ||
fileDiff.prevName === sessionDiff.prevName)
) {
// 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;
const annotationsChanged =
lineAnnotations != null &&
Expand Down
9 changes: 9 additions & 0 deletions packages/diffs/src/utils/composeCacheKey.ts
Original file line number Diff line number Diff line change
@@ -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])}`;
}
3 changes: 2 additions & 1 deletion packages/diffs/src/utils/hydratePartialDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
Hunk,
} from '../types';
import { cloneFileDiffMetadata } from './cloneFileDiffMetadata';
import { composeCacheKey } from './composeCacheKey';
import {
getHunkSideEndBoundary,
getHunkSideStartBoundary,
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 8 additions & 8 deletions packages/diffs/src/utils/parseDiffFromFile.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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';

/**
* 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`.
* 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,
Expand Down Expand Up @@ -37,12 +38,11 @@ export function parseDiffFromFile(

const fileData = processFile(patch, {
cacheKey: (() => {
const oldCacheKey = oldFile?.cacheKey ?? oldFile?.name;
const newCacheKey = newFile?.cacheKey ?? newFile?.name;
if (oldCacheKey != null && newCacheKey != null) {
return oldCacheKey + ':' + newCacheKey;
}
return oldCacheKey ?? newCacheKey;
const oldCacheKey = oldFile?.cacheKey;
const newCacheKey = newFile?.cacheKey;
return oldCacheKey != null && newCacheKey != null
? composeCacheKey('diff', oldCacheKey, newCacheKey)
: undefined;
})(),
oldFile: resolvedOldFile,
newFile: resolvedNewFile,
Expand Down
34 changes: 24 additions & 10 deletions packages/diffs/src/utils/parsePatchFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ParsedPatch,
} from '../types';
import { cleanLastNewline } from './cleanLastNewline';
import { composeCacheKey } from './composeCacheKey';
import { detachString, releaseStringDetachBuffer } from './detachString';
import {
getHunkSideEndBoundary,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -632,6 +644,8 @@ export function parsePatchFiles(
} else {
console.error(error);
}
} finally {
releaseStringDetachBuffer();
}
}
return patches;
Expand Down
Loading