fix(B04): prevent image URI flash across initial render and detail remounts

This commit is contained in:
Codex committed 2026-10-21 15:15:29 +09:00
1 parent 0b613e522d
commit 342820690f
7 files changed
+217 -24

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
"test:core": "npm run typecheck && npm run test:chat && npm run test:sessions && npm run test:persistence && npm run test:conversation", "test:core": "npm run typecheck && npm run test:chat && npm run test:sessions && npm run test:persistence && npm run test:conversation",
"test:alignment": "node scripts/run-alignment-regression.mjs", "test:alignment": "node scripts/run-alignment-regression.mjs",
"test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom", "test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom",
"test:image-lifecycle": "node scripts/test-image-lifecycle.mjs",
"test:permissions:loopback": "node scripts/build-full-access-loopback-kernel.mjs && node scripts/test-full-access-loopback.mjs --kernel", "test:permissions:loopback": "node scripts/build-full-access-loopback-kernel.mjs && node scripts/test-full-access-loopback.mjs --kernel",
"test:reporting": "node --test scripts/regression-reporting.test.mjs", "test:reporting": "node --test scripts/regression-reporting.test.mjs",
"test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs",
+1 -1
View File
@@ -18,7 +18,7 @@ npm test
| 对话/模型/权限/折叠 | `test:alignment`、`test:conversation`;交互变更加 `test:alignment:dom` | 普通 alignment 无 DOM 依赖时明确 SKIP;严格 DOM 入口缺依赖会失败。授权回归含 mock,非真实 SDK 证明 | | 对话/模型/权限/折叠 | `test:alignment`、`test:conversation`;交互变更加 `test:alignment:dom` | 普通 alignment 无 DOM 依赖时明确 SKIP;严格 DOM 入口缺依赖会失败。授权回归含 mock,非真实 SDK 证明 |
| 推荐技能/安装状态 | `test:recommended-skills`、`test:recommended-skills:dom` | 前者受控安装归档;真实发布包为单列可选检查,不能把 SKIP 算通过 | | 推荐技能/安装状态 | `test:recommended-skills`、`test:recommended-skills:dom` | 前者受控安装归档;真实发布包为单列可选检查,不能把 SKIP 算通过 |
| 能力选择/使用草稿 | `test:composer-capabilities` | 不启动真实模型;完整应用事件链另验 | | 能力选择/使用草稿 | `test:composer-capabilities` | 不启动真实模型;完整应用事件链另验 |
| 正文/代码/数学/媒体 | `test:markdown`、`test:recovered`、`test:media` | 根据实际变更选取,不代表像素一致 | | 正文/代码/数学/媒体 | `test:markdown`、`test:recovered`、`test:media`;图片异步/折叠变更加 `test:image-lifecycle` | 后者需 `YCODE_TEST_DOM_MODULE`;根据实际变更选取,不代表像素一致 |
| 文件/会话持久化 | `test:resources`、`test:persistence`、`test:sessions` | 先核对脚本临时目录与宿主隔离 | | 文件/会话持久化 | `test:resources`、`test:persistence`、`test:sessions` | 先核对脚本临时目录与宿主隔离 |
| 升级/发布 | `test:updates`、`test:release` | 不等于执行 `release` 或 `updates:deploy`;发布命令单独授权 | | 升级/发布 | `test:updates`、`test:release` | 不等于执行 `release` 或 `updates:deploy`;发布命令单独授权 |
| 设计 token/恢复闭包 | `check:tokens`、`test:fold-summary` 等 | 非零退出必须保留;已有失败不能刷新 baseline 掩盖 | | 设计 token/恢复闭包 | `check:tokens`、`test:fold-summary` 等 | 非零退出必须保留;已有失败不能刷新 baseline 掩盖 |
+113
View File
@@ -0,0 +1,113 @@
/** Own-component synthetic fixture. run() supports jsdom; probe() records real rAF geometry
* only when run in a real browser. Never reads history, host files, or a provider. */
import React, { useLayoutEffect, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import { MarkdownImage, type MarkdownImageProps } from '../src/renderer/src/workbuddy/images/MarkdownImage'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
import '../src/renderer/src/workbuddy/conversation/conversation.css'
// Valid synthetic 320×180 PNG (all chunk CRCs checked); padding remains after IEND.
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAUAAAAC0CAIAAABqhmJGAAACwUlEQVR4nO3OQQkAMQADsEqflEk5aeehnzIIREBy7gc8KvMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1H3jo+KxCh14RAAAAAElFTkSuQmCC'
const source = 'data:image/png;base64,' + btoa(atob(pixel) + '\0'.repeat(18000))
const nextSource = 'data:image/png;base64,' + btoa(atob(pixel) + '\0'.repeat(18001))
const container = document.getElementById('root')!
const root = createRoot(container)
const checks: string[] = []
const samples: object[] = []
const errors: string[] = []
const observer = new MutationObserver(records => {
for (const record of records) for (const node of record.addedNodes) {
if (node.textContent?.includes('base64,')) errors.push('URI text inserted into DOM')
}
})
observer.observe(container,{subtree:true,childList:true,characterData:true})
window.addEventListener('error', event => errors.push(event.message))
window.addEventListener('unhandledrejection', event => errors.push(String(event.reason)))
const check = (value: unknown, label: string) => { if (!value) throw Error(label); checks.push(label) }
const pause = () => new Promise<void>(resolve => setTimeout(resolve, 0))
const settle = async () => { await pause(); await pause() }
function sample(label: string) {
const text = container.textContent || ''
const images = [...container.querySelectorAll<HTMLImageElement>('img.markdown-content__image')]
const region = container.querySelector('.cr-collapse__content') || container
check(!text.includes('data:') && !text.includes('base64,') && !text.includes('AAAA'.repeat(16)), label + ': no URI text')
const value = { label, textLength: text.length, imageCount: images.length, height: region.getBoundingClientRect().height, imageHeight: images[0]?.getBoundingClientRect().height ?? 0 }
samples.push(value)
return value
}
function CommitProbe({ children, label }: { children: ReactNode; label: string }) {
useLayoutEffect(() => { sample(label + ': layout-before-passive-effects') })
return <>{children}</>
}
function mount(node: ReactNode, label: string) {
flushSync(() => root.render(<CommitProbe label={label}>{node}</CommitProbe>))
sample(label + ': sync-commit')
}
const img = () => container.querySelector<HTMLImageElement>('img.markdown-content__image')
const concise = (label: string) => { check(!img(), label + ': no image'); check((container.textContent || '').length < 160, label + ': concise'); sample(label) }
const renderImage = (props: Partial<MarkdownImageProps> & {src: string}, label: string) => mount(<MarkdownImage alt="" urlList={[]} {...props} />, label)
function deferred() { let resolve!: (s: string | undefined) => void; let reject!: (e: Error) => void; const promise = new Promise<string | undefined>((a,b) => {resolve=a;reject=b}); return {promise,resolve,reject} }
function bubble(session: string) {
return <MessageBubble key={session} sessionId={session} message={{id:session + '-message', role:'assistant', status:'done', createdAt:1, turnDurationMs:2000, parts:[{type:'reasoning', id:'synthetic-reasoning',text:'Synthetic detail'}, {type:'text',text:`![](${source})`} ]}} />
}
async function frames(label: string, count = 8) {
sample(label + ': before-rAF')
for (let i = 0; i < count; i++) await new Promise<void>(resolve => requestAnimationFrame(() => {sample(label + ': frame-' + i);resolve()}))
}
async function folds(withFrames: boolean) {
for (const session of ['synthetic-A','synthetic-B','synthetic-A']) {
mount(bubble(session),session); await settle()
for (let cycle=0;cycle<3;cycle++) {
const before = img()
const button = container.querySelector<HTMLElement>('.cr-collapse--completed > .cr-collapse__header')
check(button, 'actual CompletedFold header exists')
sample(session + ': before-toggle-' + cycle)
flushSync(() => button!.click())
sample(session + ': toggle-' + cycle)
await settle()
check(img()?.getAttribute('src') === source, 'actual MessageBubble preserves image')
check(img() !== before, 'actual CompletedFold remounts image across toggle')
if (withFrames) await frames(session + ': toggle-' + cycle)
}
}
}
async function run() {
renderImage({src:source},'cold'); check(img()?.getAttribute('src') === source,'safe first commit image'); check((samples[0] as {imageCount:number}).imageCount===1,'layout probe sees image before passive effects'); await settle(); sample('stable')
img()!.dispatchEvent(new Event('error')); await settle(); concise('native image error')
renderImage({src:nextSource},'error recovery new src'); check(img()?.getAttribute('src') === nextSource,'new safe src resets prior error synchronously')
for (const src of ['javascript:alert(1)','data:text/html,'+'A'.repeat(24000),'/synthetic/no-host.png']) {
renderImage({src},'invalid/no-host'); concise('invalid/no-host initial'); await settle(); concise('invalid/no-host settled')
}
const sameSrc='/synthetic/same.png', firstResolver=deferred(), secondResolver=deferred()
renderImage({src:sameSrc,imageUrlResolver:()=>firstResolver.promise},'same source first resolver');await settle()
renderImage({src:sameSrc,imageUrlResolver:()=>secondResolver.promise},'same source second resolver');await settle()
firstResolver.resolve(source);await settle();concise('old resolver identity ignored')
secondResolver.resolve(nextSource);await settle();check(img()?.getAttribute('src')===nextSource,'new resolver identity wins')
const local='file:///synthetic/only.png', host=deferred(), hostResolver=()=>host.promise
renderImage({src:local,imageUrlResolver:hostResolver},'local direct');check(img()?.getAttribute('src')===local,'existing unpreferred local semantics')
renderImage({src:local,imageUrlResolver:hostResolver,preferHostResolution:true},'prefer host enabled');concise('host preference removes direct file before effects');await settle()
host.resolve(source);await settle();check(img()?.getAttribute('src')===source,'preferred host result')
renderImage({src:source,imageUrlResolver:async()=>undefined},'same direct first adapter');img()!.dispatchEvent(new Event('error'));await settle();concise('direct failed before new adapter')
renderImage({src:source,imageUrlResolver:async()=>undefined},'same direct new adapter');img()!.dispatchEvent(new Event('error'));await settle();concise('immediate error after adapter reset retained')
renderImage({src:nextSource},'A to B');renderImage({src:source},'B to A retry');check(img()?.getAttribute('src')===source,'A to B to A resets old failure')
const old = deferred(), latest = deferred(); const resolved: string[] = []
const resolver = (src: string) => src.endsWith('old.png') ? old.promise : latest.promise
const onResolve = (src: string) => {resolved.push(src)}
renderImage({src:'/synthetic/old.png',imageUrlResolver:resolver,onResolve},'pending old');await settle()
renderImage({src:'/synthetic/new.png',imageUrlResolver:resolver,onResolve},'pending new');concise('no old during new');await settle()
old.resolve(source);await settle();concise('stale success ignored');check(!resolved.length,'stale success has no onResolve')
latest.resolve(nextSource);await settle();check(img()?.getAttribute('src')===nextSource,'latest result wins');check(resolved.length===1&&resolved[0].endsWith('new.png'),'latest onResolve only')
const failure=deferred();const failResolver=()=>failure.promise
renderImage({src:'/synthetic/fail.png',imageUrlResolver:failResolver},'pending failure clears previous');concise('old resolved image removed synchronously');await settle()
renderImage({src:source},'safe switch while rejection pending');failure.reject(Error('synthetic failure'));await settle();check(img()?.getAttribute('src')===source,'stale rejection cannot fail new safe image')
renderImage({src:'/synthetic/reject.png',imageUrlResolver:async()=>{throw Error('synthetic')}},'active rejection');await settle();concise('active rejection');check(container.querySelector('[aria-busy="false"]'),'failure not busy')
renderImage({src:'/synthetic/unsafe.png',imageUrlResolver:async()=> 'javascript:alert(1)'},'unsafe resolver result');await settle();concise('unsafe resolver rejected')
const abandoned=deferred();let abandonedResolve=0
renderImage({src:'/synthetic/unmount.png',imageUrlResolver:()=>abandoned.promise,onResolve:()=>abandonedResolve++},'unmount pending');await settle();mount(<span>empty</span>,'unmounted');abandoned.resolve(source);await settle();check(abandonedResolve===0,'unmounted completion ignored')
let preview: unknown; renderImage({src:source,urlList:[source],onPreviewImage:r=>{preview=r}},'preview');img()!.click();check(JSON.stringify(preview)===JSON.stringify({urlList:[source],initialIndex:0}),'safe image preview click retained')
await folds(false);check(!errors.length,'no renderer errors')
return {checks,samples,errors,limits:'DOM lifecycle only; jsdom heights are not layout acceptance. Synthetic data only.'}
}
async function probe() { await folds(true);check(img()?.complete && img()?.naturalWidth === 320,'synthetic PNG decoded in real browser');check(samples.some(s => (s as {imageHeight:number}).imageHeight > 0),'native frames include nonzero image geometry');check(samples.every(s => (s as {height:number}).height < 2000),'native region has no thousands-pixel address spike');check(!errors.length,'no renderer errors');return {checks,samples,errors,limits:'Real geometry only if executed in a real browser with fixture CSS. No user data.'} }
;(window as any).__b04 = {run,probe}
+15 -1
View File
@@ -103,7 +103,21 @@ function testMarkdownImageServerFallback(): void {
const source = 'C:\\workspace\\source-only.png' const source = 'C:\\workspace\\source-only.png'
const sourceFallback = renderToStaticMarkup(<MarkdownImage src={source} urlList={[]} />) const sourceFallback = renderToStaticMarkup(<MarkdownImage src={source} urlList={[]} />)
assert.ok(sourceFallback.includes(source)) assert.ok(!sourceFallback.includes(source), 'unresolved source is never fallback text')
assert.ok(sourceFallback.length < 500)
const synthetic = 'data:image/png;base64,' + 'A'.repeat(24000)
const first = renderToStaticMarkup(<MarkdownImage src={synthetic} alt="" urlList={[synthetic]} />)
assert.match(first, /<img /)
assert.ok(first.includes(`src="${synthetic}"`))
assert.ok(!first.includes('cr-image-block-fallback'))
assert.ok(!first.replace(/<[^>]*>/g, '').includes('data:image'))
for (const invalid of ['javascript:alert(1)', 'data:text/html,' + 'A'.repeat(24000)]) {
const markup = renderToStaticMarkup(<MarkdownImage src={invalid} alt="" urlList={[]} />)
assert.ok(markup.length < 500)
assert.ok(!markup.includes(invalid))
assert.ok(!markup.includes('<img '))
}
} }
function testPreviewDialogServerStructure(): void { function testPreviewDialogServerStructure(): void {
+23
View File
@@ -0,0 +1,23 @@
/** Offline B04 lifecycle regression. --build-only emits a standalone own-component fixture. */
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
fs.mkdirSync('.tmp',{recursive:true})
const output=fs.mkdtempSync(path.resolve('.tmp/b04-image-'))
await require('esbuild').build({entryPoints:['scripts/b04-image-lifecycle-fixture.tsx'],outfile:path.join(output,'fixture.js'),bundle:true,platform:'browser',format:'iife',jsx:'automatic',loader:{'.woff2':'dataurl','.woff':'dataurl','.ttf':'dataurl','.svg':'dataurl','.png':'dataurl'},logLevel:'error'})
fs.writeFileSync(path.join(output,'index.html'),`<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'"><link rel="stylesheet" href="fixture.css"><style>body{margin:20px;width:800px}.markdown-content__image{max-height:400px;max-width:100%}</style><div id="root"></div><pre id="report">Running synthetic own-component frame probe…</pre><script src="fixture.js"></script><script>window.__b04.probe().then(r=>{window.__b04Report=r;document.getElementById('report').textContent=JSON.stringify(r,null,2)}).catch(e=>{window.__b04Error=String(e);document.getElementById('report').textContent=String(e)})</script>`)
if(process.argv.includes('--build-only')){console.log(JSON.stringify({result:'BUILD_ONLY',output,limits:'Not executed in a native browser; open index.html in a permitted browser for synthetic consecutive-frame probe.'}));process.exit(0)}
const {JSDOM}=require(process.env.YCODE_TEST_DOM_MODULE||process.env.YCODE_JSDOM_MODULE||'jsdom')
const dom=new JSDOM('<!doctype html><div id="root"></div>',{url:'https://fixture.invalid',runScripts:'outside-only',pretendToBeVisual:true})
const w=dom.window
w.fetch=()=>{throw Error('Fixture forbids network')}
w.ResizeObserver=class {observe(){}unobserve(){}disconnect(){}}
w.matchMedia=()=>({matches:false,addEventListener(){},removeEventListener(){}})
let timer
try {
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
const report=await Promise.race([w.__b04.run(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('B04 fixture timed out after 20s')),20000)})])
fs.writeFileSync(path.join(output,'report.json'),JSON.stringify(report,null,2))
console.log(JSON.stringify({result:'PASS',checks:report.checks.length,samples:report.samples.length,output,limits:report.limits}))
}finally{clearTimeout(timer);dom.window.close()}
+30
View File
@@ -0,0 +1,30 @@
/** Optional, isolated own-component Electron frame probe; never launches the user's app.
* Build first: node scripts/test-image-lifecycle.mjs --build-only
* Then: electron scripts/verify-b04-image-native.cjs <output-directory>
* Requires a permitted graphical environment. Never pass --no-sandbox. */
const {app,BrowserWindow,session}=require('electron')
const fs=require('node:fs'),path=require('node:path'),os=require('node:os')
const fixture=path.resolve(process.argv[2]||'')
if(!process.argv[2]||!fs.existsSync(path.join(fixture,'index.html')))throw Error('Supply build-only fixture directory')
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'ycode-b04-native-'))
app.setPath('userData',profile)
let timer,win,finished=false
function finish(error,report){
if(finished)return;finished=true;clearTimeout(timer)
const result={result:error?'FAIL':'PASS',error:error?String(error):undefined,report,limits:'Synthetic own-component Electron fixture, not the installed application or original reference UI.'}
fs.writeFileSync(path.join(fixture,'native-report.json'),JSON.stringify(result,null,2))
console.log(JSON.stringify({result:result.result,error:result.error,output:path.join(fixture,'native-report.json')}))
if(win&&!win.isDestroyed())win.destroy()
app.exit(error?1:0)
}
app.whenReady().then(async()=>{
session.defaultSession.webRequest.onBeforeRequest((details,callback)=>callback({cancel:!['file:','data:','blob:','devtools:'].some(p=>details.url.startsWith(p))}))
win=new BrowserWindow({width:1000,height:800,show:true,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}})
win.webContents.on('render-process-gone',(_,details)=>finish(Error('Renderer gone: '+details.reason)))
timer=setTimeout(()=>finish(Error('Bounded native probe timed out after 20s')),20000)
await win.loadFile(path.join(fixture,'index.html'))
const report=await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const check=()=>{if(window.__b04Error)return reject(Error(window.__b04Error));if(window.__b04Report)return resolve(window.__b04Report);setTimeout(check,25)};check()})`)
if(!report.samples.some(s=>s.height>0))throw Error('No nonzero native layout measured')
if(report.samples.some(s=>s.textLength>1000||s.height>2000))throw Error('Unexpected address-sized text/height spike')
finish(null,report)
}).catch(error=>finish(error))
@@ -1,4 +1,4 @@
import { memo, useEffect, useState, type ReactElement } from 'react' import { memo, useEffect, useMemo, useState, type ReactElement } from 'react'
import './image-renderer.css' import './image-renderer.css'
import { import {
isSafeImageUrl, isSafeImageUrl,
@@ -20,7 +20,13 @@ export interface MarkdownImageProps {
} }
/** REA ev_717fa5e… src-DbE1_mgM.js [721564,722288); URL resolver is an explicit host adapter. */ /** REA ev_717fa5e… src-DbE1_mgM.js [721564,722288); URL resolver is an explicit host adapter. */
export const MarkdownImage = memo(function MarkdownImage({ export const MarkdownImage = memo(function MarkdownImage(props: MarkdownImageProps): ReactElement {
// A changed source owns a fresh lifecycle and DOM image, including A → B → A.
// This also prevents the browser retaining the previous decoded image while loading.
return <MarkdownImageSource key={props.src} {...props} />
})
function MarkdownImageSource({
src, src,
alt = '', alt = '',
urlList, urlList,
@@ -29,35 +35,40 @@ export const MarkdownImage = memo(function MarkdownImage({
preferHostResolution = false, preferHostResolution = false,
onPreviewImage onPreviewImage
}: MarkdownImageProps): ReactElement { }: MarkdownImageProps): ReactElement {
const [resolvedUrl, setResolvedUrl] = useState<string>() const request = useMemo(() => ({ src, imageUrlResolver, preferHostResolution, onResolve }),
const [failed, setFailed] = useState(false) [src, imageUrlResolver, preferHostResolution, onResolve])
const [resolution, setResolution] = useState<{
request: typeof request
url?: string
failed?: boolean
}>(() => ({ request }))
const directUrl = isSafeImageUrl(src) && !(preferHostResolution && requiresHostImageResolution(src))
? src : undefined
// Reset this component before committing children, not in a passive effect:
// an immediate image error must belong to the new request too.
if (resolution.request !== request) setResolution({ request })
// Never display a previous resolver's result, even before effect cleanup runs.
const current = resolution.request === request ? resolution : undefined
const resolvedUrl = directUrl ?? current?.url
const failed = current?.failed === true
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setFailed(false) if (directUrl) return
if (isSafeImageUrl(src) && !(preferHostResolution && requiresHostImageResolution(src))) { setResolution({ request })
setResolvedUrl(src)
return
}
setResolvedUrl(undefined)
resolveImageUrl(src, imageUrlResolver, preferHostResolution).then((resolved) => { resolveImageUrl(src, imageUrlResolver, preferHostResolution).then((resolved) => {
if (cancelled) return if (cancelled) return
if (!resolved) { setResolution({ request, url: resolved, failed: !resolved })
setFailed(true) if (resolved && resolved !== src) onResolve?.(src, resolved)
return
}
setResolvedUrl(resolved)
if (resolved !== src) onResolve?.(src, resolved)
}).catch(() => { }).catch(() => {
if (!cancelled) setFailed(true) if (!cancelled) setResolution({ request, failed: true })
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [imageUrlResolver, onResolve, preferHostResolution, src]) }, [directUrl, imageUrlResolver, onResolve, preferHostResolution, request, src])
if (!resolvedUrl || failed) { if (!resolvedUrl || failed) {
return ( return (
@@ -66,7 +77,7 @@ export const MarkdownImage = memo(function MarkdownImage({
aria-busy={!failed} aria-busy={!failed}
role="img" role="img"
> >
{alt || src} {alt || (failed ? '图片加载失败' : '图片加载中…')}
</span> </span>
) )
} }
@@ -83,9 +94,10 @@ export const MarkdownImage = memo(function MarkdownImage({
className="cr-image-block markdown-content__image" className="cr-image-block markdown-content__image"
src={resolvedUrl} src={resolvedUrl}
alt={alt} alt={alt}
onError={() => setFailed(true)} onError={() => setResolution((previous) => previous.request === request
? { request, failed: true } : previous)}
onClick={openPreview} onClick={openPreview}
draggable={false} draggable={false}
/> />
) )
}) }