fix(editor): preserve small images beyond the reusable cache target (#34998)

* fix(editor): preserve small images beyond the reusable cache target

* docs(editor): clarify retained image payload accounting

* test(editor): cover integrated tooltip and image contracts

* test(editor): trim pinned image capacity coverage to one regression case

* test(native-chat): keep byte-budget retry fixture within the file length limit

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 16:07:15 -07:00
committed by GitHub
co-authored by OrcaWin
parent 06c86c4828
commit adb3da0a3b
4 changed files with 107 additions and 16 deletions
@@ -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<string, string>()
@@ -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
@@ -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)
})
@@ -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')
@@ -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(<Transcript content={content} />)
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])(