diff --git a/src/renderer/src/components/editor/local-image-src-cache.ts b/src/renderer/src/components/editor/local-image-src-cache.ts index 9f991af4e99..5a39464ba6e 100644 --- a/src/renderer/src/components/editor/local-image-src-cache.ts +++ b/src/renderer/src/components/editor/local-image-src-cache.ts @@ -7,7 +7,7 @@ import { } from './local-image-cache-pinning' const BLOB_URL_CACHE_MAX_SIZE = 100 -// Keep retained decoded image data bounded as well as entry count. +// Bound retained encoded image payload separately from the reusable entry target. const BLOB_URL_CACHE_MAX_BYTES = 128 * 1024 * 1024 export const blobUrlCache = new Map() @@ -90,12 +90,15 @@ export function cacheLocalImageBlob( let projectedEntries = blobUrlCache.size + (previousUrl === undefined ? 1 : 0) let projectedBytes = retainedBytes - previousBytes + byteLength - // Evict unpinned entries only; refuse new retention when visible leases consume the budget. + // Active displays share the strict byte budget; the entry target bounds reusable, unleased images. while (projectedEntries > BLOB_URL_CACHE_MAX_SIZE || projectedBytes > BLOB_URL_CACHE_MAX_BYTES) { const candidate = Array.from(blobUrlCache.keys()).find( (candidateKey) => candidateKey !== key && !isLocalImageCacheKeyPinned(candidateKey) ) if (candidate === undefined) { + if (isLocalImageCacheKeyPinned(key) && projectedBytes <= BLOB_URL_CACHE_MAX_BYTES) { + break + } URL.revokeObjectURL(url) cleanupLocalImageCacheKeyVersion(key) return false diff --git a/src/renderer/src/components/editor/rich-markdown-image-capacity.test.ts b/src/renderer/src/components/editor/rich-markdown-image-capacity.test.ts new file mode 100644 index 00000000000..0c8f9195443 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-image-capacity.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { blobUrlCache } from './local-image-src-cache' +import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' +import { resetLocalImageSrcStateForTests } from './useLocalImageSrc' + +const editors: Editor[] = [] +const originalApi = Object.getOwnPropertyDescriptor(window, 'api') +const readFile = vi.fn(async () => ({ + content: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXioAAAAASUVORK5CYII=', + isBinary: true, + mimeType: 'image/png' +})) +let nextUrl = 0 + +function mountImages(count: number) { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = new Editor({ + element: host, + extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }), + content: Array.from({ length: count }, (_, index) => `![](image-${index}.png)`).join('\n\n'), + contentType: 'markdown' + }) + editors.push(editor) + setRichMarkdownImageResolverContext(editor, { filePath: '/repo/docs/readme.md' }) + return { editor, host } +} + +beforeEach(() => { + resetLocalImageSrcStateForTests() + readFile.mockClear() + nextUrl = 0 + Object.defineProperty(window, 'api', { value: { fs: { readFile } }, configurable: true }) + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:capacity-${nextUrl++}`) + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) +}) + +afterEach(() => { + for (const editor of editors.splice(0)) { + if (!editor.isDestroyed) { + editor.destroy() + } + } + resetLocalImageSrcStateForTests() + vi.restoreAllMocks() + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + document.body.innerHTML = '' +}) + +it('displays every image in a 101-image document within the byte budget', async () => { + const view = mountImages(101) + await vi.waitFor(() => { + expect(view.host.querySelectorAll('img[src]')).toHaveLength(101) + }) + expect(readFile).toHaveBeenCalledTimes(101) + expect(blobUrlCache.size).toBe(101) + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + const prewarm = prewarmMarkdownPreviewLocalImages('![](unleased.png)', '/repo/docs/readme.md') + await prewarm.done + expect(blobUrlCache.size).toBe(101) + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1) + view.editor.destroy() + expect(blobUrlCache.size).toBe(100) + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2) +}) + +it('keeps an entirely unleased prewarm within the entry target', async () => { + const prewarm = prewarmMarkdownPreviewLocalImages( + Array.from({ length: 101 }, (_, index) => `![](prewarm-${index}.png)`).join('\n\n'), + '/repo/docs/readme.md', + { limit: 101 } + ) + await prewarm.done + expect(readFile).toHaveBeenCalledTimes(101) + expect(blobUrlCache.size).toBe(100) + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 83c6aa870f2..4171e110393 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -328,14 +328,14 @@ describe('loadLocalImageSrc', () => { } }) - it('fails closed when pinned previews already consume the entry or byte budget', () => { + it('refuses unleased overflow and oversized payloads when pinned previews fill the entry target', () => { for (let index = 0; index < 100; index += 1) { const key = `pinned-${index}` pinLocalImageCache(key) expect(cacheLocalImageBlob(key, `blob:${index}`, 1)).toBe(true) } - expect(cacheLocalImageBlob('pinned-overflow', 'blob:overflow', 1)).toBe(false) + expect(cacheLocalImageBlob('unleased-overflow', 'blob:overflow', 1)).toBe(false) expect(blobUrlCache.size).toBe(100) expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:overflow') diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx index 261bd522447..e59a93a4d93 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx @@ -11,6 +11,7 @@ import { resetLocalImageSrcStateForTests } from '@/components/editor/useLocalImageSrc' import { useAppStore } from '@/store' +import * as imageCache from '@/components/editor/local-image-src-cache' import { NativeChatMarkdown } from './NativeChatMarkdown' import { NativeChatMarkdownImageOwnerContext, @@ -317,12 +318,11 @@ describe('native chat Markdown image preview', () => { expect(trigger).toHaveStyle({ aspectRatio: '640 / 360' }) }) - it('retries a cache-budget rejection on open after another image releases its lease', async () => { + it('retries a cache-byte-budget rejection on open after another image releases its lease', async () => { vi.stubGlobal('IntersectionObserver', ImageVisibilityObserver) - const content = Array.from( - { length: 101 }, - (_, index) => `![Chart ${index}](chart-${index}.png)` - ).join('\n\n') + imageCache.pinLocalImageCache('reserved') + expect(imageCache.cacheLocalImageBlob('reserved', 'blob:reserved', 2 ** 27 - 2)).toBe(true) + const content = [0, 1, 2].map((i) => `![Chart ${i}](chart-${i}.png)`).join('\n\n') const { container } = render() const triggers = screen.getAllByRole('button') expect(mocks.readRuntimeFilePreview).not.toHaveBeenCalled() @@ -331,13 +331,13 @@ describe('native chat Markdown image preview', () => { ImageVisibilityObserver.current.emit(trigger, true) } }) - expect(container.querySelectorAll('img')).toHaveLength(100) - expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(101) + expect(container.querySelectorAll('img')).toHaveLength(2) + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(3) act(() => ImageVisibilityObserver.current.emit(triggers[0]!, false)) - fireEvent.click(triggers[100]!) - const dialog = screen.getByRole('dialog', { name: 'Chart 100' }) - expect(await within(dialog).findByRole('img')).toHaveAttribute('src', 'blob:image-102') - expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(102) + fireEvent.click(triggers[2]!) + const dialog = screen.getByRole('dialog', { name: 'Chart 2' }) + expect(await within(dialog).findByRole('img')).toHaveAttribute('src', 'blob:image-4') + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(4) fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })) act(() => { for (const trigger of triggers) { @@ -345,7 +345,8 @@ describe('native chat Markdown image preview', () => { } }) expect(container.querySelectorAll('img')).toHaveLength(0) - expect(URL.revokeObjectURL).toHaveBeenCalledTimes(102) + imageCache.unpinLocalImageCache('reserved') + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(4) }) it.each([false, true])(