fix(B04): prevent image URI flash across initial render and detail remounts
This commit is contained in:
1 parent
0b613e522d
commit
342820690f
7 files changed
+217
-24
No files matched your search
@@ -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:``} ]}} />
|
||||
}
|
||||
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}
|
||||
Reference in new issue
Block a user