@@ -30,35 +30,116 @@ interface MarkdownContentProps {
3030 missionId ?: string ;
3131}
3232
33- // Global cache for fetched image URLs with automatic cleanup
34- // Uses a simple LRU-style eviction: when cache exceeds limit, oldest entries are revoked
33+ // Global, refcounted cache of fetched image blob URLs.
34+ //
35+ // Why refcounted: the previous implementation revoked the oldest URL on
36+ // LRU eviction, but that URL was often still the `src=` of a mounted
37+ // `<img>` element somewhere on the page (the URL lives in component
38+ // state, not just in this map). Revoking it under the running DOM made
39+ // the browser show its broken-image icon — but a click-through to the
40+ // preview modal triggered a fresh fetch and worked, which matched the
41+ // reported "image fails until I click the broken icon" symptom exactly.
42+ //
43+ // New contract: every consumer that uses a URL from this cache must call
44+ // `acquireCachedImageUrl` (or `cacheAndAcquireImageUrl` if it just
45+ // fetched) and pair it with a later `releaseImageUrl`. The cache only
46+ // revokes a URL when its refcount reaches zero AND the entry has been
47+ // LRU-evicted, so a URL stuck in a mounted `<img>` keeps working until
48+ // that `<img>` unmounts.
3549const IMAGE_CACHE_LIMIT = 50 ;
36- const imageUrlCache = new Map < string , string > ( ) ;
3750
38- function cacheImageUrl ( path : string , url : string ) : void {
39- // If already cached, revoke the duplicate URL and update access order
40- if ( imageUrlCache . has ( path ) ) {
41- // Revoke the incoming duplicate URL to prevent memory leak from concurrent fetches
51+ interface ImageCacheEntry {
52+ url : string ;
53+ refCount : number ;
54+ /** Marked when LRU-evicted but kept alive because refCount > 0. The
55+ * next `releaseImageUrl` that drops refCount to 0 will revoke and
56+ * drop the entry instead of leaving it indefinitely. */
57+ evicted : boolean ;
58+ }
59+
60+ const imageUrlCache = new Map < string , ImageCacheEntry > ( ) ;
61+
62+ /** Try to read a cached URL for `path` and acquire a reference. Returns
63+ * null when not cached. Callers must pair every successful return with
64+ * exactly one `releaseImageUrl(path)` on unmount. */
65+ function acquireCachedImageUrl ( path : string ) : string | null {
66+ const entry = imageUrlCache . get ( path ) ;
67+ if ( ! entry ) return null ;
68+ entry . refCount ++ ;
69+ // Re-acquired by a new consumer, so the entry is back to useful —
70+ // clear any stale eviction mark from a prior overflow that didn't
71+ // actually need to revoke this URL after all.
72+ entry . evicted = false ;
73+ return entry . url ;
74+ }
75+
76+ /** Insert a freshly-fetched blob URL and acquire one reference. Caller
77+ * owns the reference and must release it on unmount. Concurrent fetches
78+ * for the same path collapse onto the existing entry — the duplicate
79+ * URL is revoked here so the caller's later `releaseImageUrl` decrements
80+ * the canonical entry's refCount instead of leaking. */
81+ function cacheAndAcquireImageUrl ( path : string , url : string ) : string {
82+ const existing = imageUrlCache . get ( path ) ;
83+ if ( existing ) {
4284 URL . revokeObjectURL ( url ) ;
43- const existingUrl = imageUrlCache . get ( path ) ! ;
44- imageUrlCache . delete ( path ) ;
45- imageUrlCache . set ( path , existingUrl ) ;
46- return ;
85+ existing . refCount ++ ;
86+ // The entry is being acquired again, so it's clearly still useful —
87+ // clear any prior eviction mark so a subsequent release doesn't
88+ // revoke a URL we're actively re-using.
89+ existing . evicted = false ;
90+ return existing . url ;
4791 }
4892
49- // Evict oldest entries if at limit
93+ // LRU eviction strategy:
94+ // 1. Drop refCount=0 entries first — they're safe to revoke immediately.
95+ // 2. If everything is referenced, mark only the OLDEST entry (first
96+ // in insertion order) as `evicted`, so its last consumer's
97+ // `releaseImageUrl` revokes it. We don't mark more than one —
98+ // that would needlessly destroy cache effectiveness for entries
99+ // that callers might still re-acquire. If the working set really
100+ // exceeds the cap, future inserts will mark additional entries
101+ // one-by-one as they overflow.
102+ // 3. The cache may temporarily exceed `IMAGE_CACHE_LIMIT` when all
103+ // entries are referenced; that's fine and self-corrects as
104+ // consumers unmount.
50105 while ( imageUrlCache . size >= IMAGE_CACHE_LIMIT ) {
106+ let dropped = false ;
107+ for ( const [ key , entry ] of imageUrlCache ) {
108+ if ( entry . refCount === 0 ) {
109+ URL . revokeObjectURL ( entry . url ) ;
110+ imageUrlCache . delete ( key ) ;
111+ dropped = true ;
112+ break ;
113+ }
114+ }
115+ if ( dropped ) continue ;
116+
117+ // No droppable entries — every URL is live. Mark only the oldest
118+ // for revoke-on-last-release and stop.
51119 const oldestKey = imageUrlCache . keys ( ) . next ( ) . value ;
52120 if ( oldestKey ) {
53- const oldUrl = imageUrlCache . get ( oldestKey ) ;
54- if ( oldUrl ) {
55- URL . revokeObjectURL ( oldUrl ) ;
121+ const oldest = imageUrlCache . get ( oldestKey ) ;
122+ if ( oldest && ! oldest . evicted ) {
123+ oldest . evicted = true ;
56124 }
57- imageUrlCache . delete ( oldestKey ) ;
58125 }
126+ break ;
59127 }
60128
61- imageUrlCache . set ( path , url ) ;
129+ imageUrlCache . set ( path , { url, refCount : 1 , evicted : false } ) ;
130+ return url ;
131+ }
132+
133+ /** Decrement the refcount for `path`. If it reaches zero AND the entry
134+ * was previously LRU-evicted, revoke the URL and drop it. */
135+ function releaseImageUrl ( path : string ) : void {
136+ const entry = imageUrlCache . get ( path ) ;
137+ if ( ! entry ) return ;
138+ entry . refCount = Math . max ( 0 , entry . refCount - 1 ) ;
139+ if ( entry . refCount === 0 && entry . evicted ) {
140+ URL . revokeObjectURL ( entry . url ) ;
141+ imageUrlCache . delete ( path ) ;
142+ }
62143}
63144
64145function isFilePath ( str : string ) : boolean {
@@ -155,8 +236,13 @@ function FilePreviewModalContent({
155236 const FileIcon = getFileIcon ( path ) ;
156237 const fileName = path . split ( "/" ) . pop ( ) || "file" ;
157238
158- const [ imageUrl , setImageUrl ] = useState < string | null > ( imageUrlCache . get ( resolvedPath ) || null ) ;
159- const [ loading , setLoading ] = useState ( ! imageUrl && isImage ) ;
239+ // imageUrl initial state is null on purpose — the effect below acquires
240+ // (and refcounts) the cached URL on mount instead of reading the cache
241+ // directly here. Reading cache without acquiring would let LRU eviction
242+ // revoke the URL out from under this component's `<img src>`, which is
243+ // the bug this refcount fix is closing.
244+ const [ imageUrl , setImageUrl ] = useState < string | null > ( null ) ;
245+ const [ loading , setLoading ] = useState ( isImage ) ;
160246 const [ error , setError ] = useState < string | null > ( null ) ;
161247 const [ fileSize , setFileSize ] = useState < number | null > ( null ) ;
162248 const [ downloading , setDownloading ] = useState ( false ) ;
@@ -167,9 +253,22 @@ function FilePreviewModalContent({
167253
168254 // Fetch image on mount
169255 useEffect ( ( ) => {
170- if ( ! isImage || imageUrl ) return ;
256+ if ( ! isImage ) return ;
171257
172258 let cancelled = false ;
259+ let acquired = false ;
260+
261+ const cached = acquireCachedImageUrl ( resolvedPath ) ;
262+ if ( cached ) {
263+ setImageUrl ( cached ) ;
264+ setLoading ( false ) ;
265+ acquired = true ;
266+ return ( ) => {
267+ cancelled = true ;
268+ if ( acquired ) releaseImageUrl ( resolvedPath ) ;
269+ } ;
270+ }
271+
173272 const fetchImage = async ( ) => {
174273 const API_BASE = getRuntimeApiBase ( ) ;
175274 const params = new URLSearchParams ( { path : resolvedPath } ) ;
@@ -187,8 +286,15 @@ function FilePreviewModalContent({
187286 const blob = await res . blob ( ) ;
188287 if ( ! cancelled ) setFileSize ( blob . size ) ;
189288 const url = URL . createObjectURL ( blob ) ;
190- cacheImageUrl ( resolvedPath , url ) ;
191- if ( ! cancelled ) setImageUrl ( url ) ;
289+ if ( cancelled ) {
290+ // Component unmounted before fetch landed — don't put this URL
291+ // into the cache where it would leak; just revoke it directly.
292+ URL . revokeObjectURL ( url ) ;
293+ return ;
294+ }
295+ const stored = cacheAndAcquireImageUrl ( resolvedPath , url ) ;
296+ acquired = true ;
297+ setImageUrl ( stored ) ;
192298 } catch ( err ) {
193299 if ( ! cancelled ) setError ( err instanceof Error ? err . message : "Failed to load" ) ;
194300 } finally {
@@ -197,8 +303,11 @@ function FilePreviewModalContent({
197303 } ;
198304
199305 fetchImage ( ) ;
200- return ( ) => { cancelled = true ; } ;
201- } , [ isImage , imageUrl , resolvedPath ] ) ;
306+ return ( ) => {
307+ cancelled = true ;
308+ if ( acquired ) releaseImageUrl ( resolvedPath ) ;
309+ } ;
310+ } , [ isImage , resolvedPath , workspaceId , missionId ] ) ;
202311
203312 // Fetch text preview on mount
204313 useEffect ( ( ) => {
@@ -546,13 +655,25 @@ function InlineImagePreview({
546655 missionId ?: string ;
547656} ) {
548657 const resolvedPath = resolvePath ( path , basePath ) ;
549- const [ imageUrl , setImageUrl ] = useState < string | null > ( imageUrlCache . get ( resolvedPath ) || null ) ;
550- const [ loading , setLoading ] = useState ( ! imageUrl ) ;
658+ const [ imageUrl , setImageUrl ] = useState < string | null > ( null ) ;
659+ const [ loading , setLoading ] = useState ( true ) ;
551660 const [ error , setError ] = useState < string | null > ( null ) ;
552661
553662 useEffect ( ( ) => {
554- if ( imageUrl ) return ;
555663 let cancelled = false ;
664+ let acquired = false ;
665+
666+ const cached = acquireCachedImageUrl ( resolvedPath ) ;
667+ if ( cached ) {
668+ setImageUrl ( cached ) ;
669+ setLoading ( false ) ;
670+ acquired = true ;
671+ return ( ) => {
672+ cancelled = true ;
673+ if ( acquired ) releaseImageUrl ( resolvedPath ) ;
674+ } ;
675+ }
676+
556677 const fetchImage = async ( ) => {
557678 const API_BASE = getRuntimeApiBase ( ) ;
558679 const params = new URLSearchParams ( { path : resolvedPath } ) ;
@@ -569,17 +690,25 @@ function InlineImagePreview({
569690 }
570691 const blob = await res . blob ( ) ;
571692 const url = URL . createObjectURL ( blob ) ;
572- cacheImageUrl ( resolvedPath , url ) ;
573- if ( ! cancelled ) setImageUrl ( url ) ;
693+ if ( cancelled ) {
694+ URL . revokeObjectURL ( url ) ;
695+ return ;
696+ }
697+ const stored = cacheAndAcquireImageUrl ( resolvedPath , url ) ;
698+ acquired = true ;
699+ setImageUrl ( stored ) ;
574700 } catch ( err ) {
575701 if ( ! cancelled ) setError ( err instanceof Error ? err . message : "Failed to load" ) ;
576702 } finally {
577703 if ( ! cancelled ) setLoading ( false ) ;
578704 }
579705 } ;
580706 fetchImage ( ) ;
581- return ( ) => { cancelled = true ; } ;
582- } , [ imageUrl , resolvedPath , workspaceId , missionId ] ) ;
707+ return ( ) => {
708+ cancelled = true ;
709+ if ( acquired ) releaseImageUrl ( resolvedPath ) ;
710+ } ;
711+ } , [ resolvedPath , workspaceId , missionId ] ) ;
583712
584713 if ( error ) {
585714 return (
0 commit comments