mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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) => ``).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('', '/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) => ``).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) => ``
|
||||
).join('\n\n')
|
||||
imageCache.pinLocalImageCache('reserved')
|
||||
expect(imageCache.cacheLocalImageBlob('reserved', 'blob:reserved', 2 ** 27 - 2)).toBe(true)
|
||||
const content = [0, 1, 2].map((i) => ``).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])(
|
||||
|
||||
Reference in New Issue
Block a user