From 477e7c95727ec54c0599fbb7d42f8bdb69b00a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=9C=E7=81=AF?= Date: Tue, 20 Oct 2026 01:32:07 +0800 Subject: [PATCH 1/2] fix: prevent cached avatar fallback on session return Prewarm used local avatar blobs asynchronously and select changed image sources during render. Model: gpt-6.1-sol --- ...-10-20-message-avatar-switch-continuity.md | 117 ++++++++++++ ...-20-message-avatar-switch-continuity.zh.md | 95 ++++++++++ packages/components/src/hooks/README.md | 13 ++ .../src/hooks/use-stable-avatar-src.ts | 25 ++- packages/components/src/lib/avatar-cache.ts | 27 ++- .../tests/cached-avatar-img.test.ts | 170 +++++++++++++++++- 6 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md create mode 100644 .agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md diff --git a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md new file mode 100644 index 0000000000..8bc4fa8eb1 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md @@ -0,0 +1,117 @@ +# Message avatar continuity across session switches + +Status: proposed +Translation: current + +[中文](2026-10-20-message-avatar-switch-continuity.zh.md) + +## Abstract + +Message avatars can briefly show a fallback when returning to a conversation even +though the image bytes are cached. A bounded local correction now preloads the +cached blob used by a stable avatar and removes an effect-delayed source update. +The same isolated Chromium experiment changes from a fallback to an immediately +loaded image on the first blob return, with one avatar HTTP request on both runs. +Decoding runs asynchronously only for used avatars, so cache reads and first paint +do not wait and startup does not decode every stored avatar. Missing sender +metadata remains a separate, unresolved condition; this note remains proposed for +that investigation and does not attribute a deployed switch to one cause. + +## Responsibilities and chosen scope + +`lib/avatar-cache.ts` owns the renderer-wide URL-keyed memory map and persistent +`lody-avatar-image-v2` CacheStorage bucket. Reads after 24 hours serve the old image +and revalidate in the background; equal bytes retain the existing URL. Navigation +never clears this cache. These keys, TTL and byte-refresh behavior are unchanged. + +`hooks/use-stable-avatar-src.ts` preserves the initial remote URL on a cold mount +while filling the cache. It now requests preloading of that local blob after fill, +or when a restored entry is used. The entry retains one off-DOM `Image`, loaded +with asynchronous decoding, for reuse across mounts. Replacing an already warmed +entry warms its successor. Startup restoration alone does not decode stored images. +No first paint or cache read awaits decoding, and preloading never fetches a remote +URL. There are no new timers, subscriptions, profile caches or retained session DOM. + +The hook also tracks the input identity alongside its resolved source. New, +removed or newly available inputs derive their current value during render. +Source changes therefore do not commit the previous user's image or wait for an +effect to select an already available image. Background state updates retain the +existing source cleanup fence. + +`UserAvatar` still delegates image readiness and broken-image fallback to +`@lody/ui` and Base UI 1.7.0. Its synchronous `complete`/`naturalWidth` path can +reuse a warmed image. A completely cold image, an unfinished preload or browser +memory-pressure eviction can still require the normal loading fallback. + +The [sender identity decision](../../implemented/feature/2026-09-08-chat-sender-identity.md) +and [UI avatar migration](../../implemented/feature/2026-09-13-ui-avatar-kbd.md) +remain applicable. This correction changes neither identity nor avatar appearance. + +## Synthetic browser evidence + +Checkout `4cc74cae8` with existing unrelated working-tree changes, Node 24.14.0, +and headless Chromium 145.0.7632.6. A temporary Vite page imports the production +`UserAvatar`, stable-source hook, cache and UI primitive. A local synthetic SVG +is the only avatar; no product data or external image server is involved. + +| State | Before: immediate / next frame | After: immediate / next frame | +| --------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------- | +| Original URL loaded; blob bytes cached; leave for an avatar-free surface and return using the blob for the first time | Fallback / fallback | Loaded / loaded | +| Return again with the same decoded blob and available metadata | Loaded / loaded | Loaded / loaded | +| Return with metadata absent, while the blob remains hot and decoded | Fallback / fallback | Fallback / fallback | +| Supply metadata to that mounted avatar | Fallback / loaded | Loaded / loaded | + +Each run serves one avatar HTTP request. The original URL is loaded before the +first return, and both returns reuse the same blob URL within that run. Samples +observe DOM availability before paint, not pixel presentation or a guaranteed +number of delayed frames. A hot decoded return is already immediate before the +correction; not every Base UI remount flashes. + +## Lifecycle and Effect assessment + +The stable-source hook triggers preloading; the shared cache entry owns its image +and decode. Component cleanup fences local updates without aborting an image +another avatar may use. Existing entry replacement, delayed object-URL revocation +and memory clearing own release. Decode rejection is caught and preserves the +existing fallback. One image object is retained per used cached avatar; the browser +still controls decoded-pixel retention. No new wait is added before UI handoff. + +Effect feature/fix assessment: this is a browser adapter and React source-derivation +correction within an existing Promise cache. A complete native unit would also +change fetch, revalidation, persistence and application ownership. That exceeds +this small behavior fix, so migration is deferred without a runtime/dependency or +ledger change. + +## Remaining profile investigation + +`ai-gui/index.tsx` first resolves the sender through `auth:getUserById`. Without +`user.image`, the image cache has no lookup key. The authenticated adapter shares +active queries, but its source registry and hook-local retained snapshots are not +a durable profile cache across all unmounts. `SessionChatInterface` also watches +the current user's profile for pending messages, so an existing same-user watch +can preserve it: a metadata miss must be observed, not assumed for each switch. + +If a matching deployed recording confirms missing metadata, evaluate existing +account/workspace-scoped identity as an initial source, preserving authoritative +updates, deletion and account/workspace invalidation. Do not broaden this patch +into a generic stale-query cache or relax stale-access protections. Queries remain +unchanged in the local correction; genuinely absent metadata retains its fallback. + +## Verification and limits + +Five avatar-cache, seven avatar-source/component and five sender-identity tests +pass; component type-checking passes. New cases render the real `UserAvatar` with +explicitly signalled browser image loads and assert original first display, +warmed return, restored offline return, decoding failure and first-commit source +arrival/change/removal. A temporary ablation with unpatched source fails the warm +return and first-commit regressions; the patched sources are restored afterward. + +Synthetic source, before/after browser results and ablation output are outside the +repository at `/private/tmp/lody-avatar-investigation-20261020/`. The existing +browser tab could not be connected for live validation. No deployed revision, +live metadata gap, Electron behavior, memory-pressure eviction or product timing +was measured. In an isolated checkout based on `7d6695cf3`, `pnpm check` and +`pnpm format` pass, including all 586 component test files (5,739 tests), CLI tests, +type-checking, lint and repository boundaries. Documentation checking retains six +existing broken links into absent ACP submodules and reports no new errors. +No dependency or Spec changed. diff --git a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md new file mode 100644 index 0000000000..c65b188373 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md @@ -0,0 +1,95 @@ +# 会话切换时的消息头像连续显示 + +Status: proposed +Translation: current + +[English](2026-10-20-message-avatar-switch-continuity.md) + +## 摘要 + +切回会话时,即使图片字节已经缓存,消息头像也可能短暂显示占位图。一个小范围本地 +修复现在会预加载稳定头像实际使用的缓存 blob,并消除通过 effect 延迟更新来源的 +额外阶段。同一 Chromium 隔离实验中,首次切回 blob 从占位图变为立即显示已加载 +图片,前后均只有一次头像 HTTP 请求。解码异步进行且仅针对实际使用的头像,缓存 +读取和首次显示不等待,启动也不会解码所有已存头像。发送者资料暂缺仍是另一条尚未 +解决的条件;本笔记为该调查保留提案状态,没有将线上现象归因到唯一原因。 + +## 职责与本次范围 + +`lib/avatar-cache.ts` 拥有按 URL 索引、跨会话共享的渲染进程内存缓存,以及 +`lody-avatar-image-v2` CacheStorage 持久缓存。超过 24 小时后的读取先返回旧图, +再后台校验;字节不变则保留现有 URL。导航不会清理该缓存。缓存键、TTL 和图片字节 +更新策略均不变。 + +`hooks/use-stable-avatar-src.ts` 在冷挂载时保留首次使用的原始 URL,同时填充缓存。 +现在它在填充完成或实际使用恢复条目时,请求预加载这个本地 blob。条目保留一个采用 +异步解码的离屏 `Image`,供后续挂载复用。已预热条目的替换也会预热新条目。仅启动 +恢复不会解码所有已存图片。首次显示和缓存读取均不等待解码;预加载不抓取远程 URL。 +没有增加定时器、订阅、用户资料缓存或会话 DOM 保留。 + +Hook 还将输入身份与解析结果一起保存。新来源、移除来源和刚取得的来源在渲染时直接 +派生当前值。因此来源变化不会提交上一个用户的图片,也不需要等待 effect 才选择已经 +可用的图片。后台状态更新继续遵循原有来源清理边界。 + +`UserAvatar` 仍然将图片就绪和坏图占位交给 `@lody/ui` 与 Base UI 1.7.0。 +它的同步 `complete` / `naturalWidth` 路径可以复用已预热图片。完全冷的图片、尚未 +完成的预加载或浏览器内存压力淘汰,仍可能需要正常加载占位。 + +[发送者身份决策](../../implemented/feature/2026-09-08-chat-sender-identity.md)和 +[UI 头像迁移](../../implemented/feature/2026-09-13-ui-avatar-kbd.md)仍然适用。 +本修复不改变身份或头像外观。 + +## 合成浏览器证据 + +实验基于 `4cc74cae8` 及工作区已有的无关改动,使用 Node 24.14.0 和无界面的 +Chromium 145.0.7632.6。临时 Vite 页面导入生产 `UserAvatar`、稳定来源 Hook、 +缓存和 UI 原语。唯一头像是本地生成的合成 SVG,不涉及产品数据或外部图片服务器。 + +| 状态 | 修复前:立即/下一帧 | 修复后:立即/下一帧 | +| ------------------------------------------------------------------------- | -------------------- | -------------------- | +| 原始 URL 已加载、blob 字节已缓存;切到没有头像的界面后返回,首次使用 blob | 占位/占位 | 已加载/已加载 | +| 同一个 blob 已解码,资料就绪,再次返回 | 已加载/已加载 | 已加载/已加载 | +| blob 仍然热缓存且已解码,但返回时缺少资料 | 占位/占位 | 占位/占位 | +| 为该已挂载头像补上资料 | 占位/已加载 | 已加载/已加载 | + +前后两次实验各只有一次头像 HTTP 请求到达本地服务器。第一次返回前,原始 URL +已经加载;每次实验内的两次返回复用同一个缓存 blob URL。样本观察绘制前的 DOM +可用状态,不是像素呈现测量,也不保证延迟帧数。已解码的热返回在修复前就能立即 +显示,不能说每次 Base UI 重新挂载都会闪。 + +## 生命周期与 Effect 评估 + +稳定来源 Hook 触发预加载,共享缓存条目拥有图片及解码。组件卸载会阻止其本地更新, +但不终止其他头像可能使用的共享图片。沿用已有的条目替换、对象 URL 延迟撤销和内存 +清理职责释放资源。解码拒绝被处理,保留已有占位。每个实际使用的缓存头像保留一个 +图片对象;解码像素是否继续保留仍由浏览器控制。UI 交接前不增加新的等待。 + +Effect 功能/修复评估:本次是在已有 Promise 缓存中修正浏览器适配和 React 来源 +派生。完整原生单元还需改变抓取、后台校验、持久化和应用所有权,超出这个小范围行为 +修复,因此延后迁移,不增加运行时或依赖,也不改变迁移台账。 + +## 尚待调查的用户资料路径 + +`ai-gui/index.tsx` 先通过 `auth:getUserById` 解析发送者。缺少 `user.image` 时, +图片缓存没有查询键。认证适配器共享活跃查询,但查询源注册表和 Hook 内保留快照 +不是跨所有卸载的持久资料缓存。`SessionChatInterface` 也会为待发送消息订阅当前 +用户资料,因此已有的同用户订阅可能保住结果;不能假定每次切换都缺资料,必须观察。 + +如果匹配部署版本的录制确认资料缺失,再评估用已有账户/工作区范围内身份提供初始 +来源,并保留权威更新、删除以及账户/工作区失效逻辑。不将本次补丁扩展成通用过期 +查询缓存,不放宽过期访问数据的保护。本地修复没有改变查询;确实缺资料时保留占位。 + +## 验证与限制 + +头像缓存五项、头像来源/组件七项、发送者身份五项测试通过,组件类型检查通过。 +新增用例渲染真实 `UserAvatar`,显式控制浏览器图片加载,检查原图首次显示、预热 +返回、持久恢复后的离线返回、解码失败,以及资料到达/变化/移除时的首次提交。 +临时换回未修复源码时,预热返回和首次提交两项回归用例失败;随后恢复了修复源码。 + +合成源码、浏览器前后结果和消融输出保存在仓库外的 +`/private/tmp/lody-avatar-investigation-20261020/`。未能连接已有浏览器页面完成线上 +验证。没有测量部署版本、真实资料缺口、Electron 行为、内存压力淘汰或产品时延。 +基于 `7d6695cf3` 的隔离检出中,`pnpm check` 和 `pnpm format` 通过,包含组件包 +全部 586 个测试文件(5,739 项测试)、CLI 测试、类型检查、lint 和仓库边界检查。 +文档检查保留六个指向缺失 ACP 子模块的既有坏链接,没有新增错误。没有修改依赖 +或 Spec。 diff --git a/packages/components/src/hooks/README.md b/packages/components/src/hooks/README.md index b1a180cf96..6b544a6eac 100644 --- a/packages/components/src/hooks/README.md +++ b/packages/components/src/hooks/README.md @@ -36,6 +36,19 @@ Missing or mismatched Providers expose no catalog; extension changes invalidate old capabilities until a matching probe arrives. A matching extension catalog must not be read as though no extensions were selected. +## Avatar sources + +`use-stable-avatar-src.ts` keeps the initial remote URL on a cold mount while +filling the byte cache, then asks `lib/avatar-cache.ts` to preload and asynchronously +decode that local blob for a return visit. Only avatars used by this hook are +decoded; startup restoration does not decode the entire persistent cache. The +cache entry retains the off-DOM image for the same lifetime as its blob URL. +Source changes select the current URL synchronously, including delayed profile +arrival and image removal, while background callbacks remain tied to their source. +First paint and cache reads never await decoding. Fully cold images and missing +profile data can still show a fallback. Evidence and limits: +[avatar continuity](../../../../.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md). + ## Cloud query recovery `use-recoverable-convex-query.ts` owns the authenticated query adapter. Each client, diff --git a/packages/components/src/hooks/use-stable-avatar-src.ts b/packages/components/src/hooks/use-stable-avatar-src.ts index 54b027cd54..45c9001670 100644 --- a/packages/components/src/hooks/use-stable-avatar-src.ts +++ b/packages/components/src/hooks/use-stable-avatar-src.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { peekAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache'; +import { peekAvatarBlobUrl, preloadAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache'; /** * Resolve an avatar URL to a locally-cached blob URL, cache-first, and swap to @@ -15,19 +15,25 @@ import { peekAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache'; * - No hot blob yet: paint the raw URL now — it IS the current remote, served * from the browser HTTP cache — and fill the blob cache in the background * WITHOUT swapping. The bytes are identical, so a raw→blob swap would only - * cause a re-decode flicker; the blob is used on the next mount. + * cause a re-decode flicker; the cache warms the blob for the next mount. * * `useCachedAvatarSrc` deliberately DOES swap raw→blob on first load — it is * tuned for GitHub owner avatars whose short `max-age` makes the raw URL an * unreliable paint, so the blob is the first dependable one. Don't conflate. */ export function useStableAvatarSrc(src?: string | null): string | undefined { - const [avatarSrc, setAvatarSrc] = useState( - () => peekAvatarBlobUrl(src) ?? src - ); + const [avatar, setAvatar] = useState(() => ({ src, resolved: peekAvatarBlobUrl(src) ?? src })); + // A profile/source change must paint its own image in this commit, including + // undefined → URL after metadata arrives, without showing the previous user. + const avatarSrc = avatar.src === src ? avatar.resolved : (peekAvatarBlobUrl(src) ?? src); useEffect(() => { let cancelled = false; + const setAvatarSrc = (resolved: string | null | undefined) => { + setAvatar((current) => + current.src === src && current.resolved === resolved ? current : { src, resolved } + ); + }; if (src == null) { setAvatarSrc(src); @@ -35,6 +41,7 @@ export function useStableAvatarSrc(src?: string | null): string | undefined { const cachedBlobUrl = peekAvatarBlobUrl(src); if (cachedBlobUrl != null) { // Local copy is hot: paint it now, revalidate the remote, swap on change. + preloadAvatarBlobUrl(src); setAvatarSrc(cachedBlobUrl); void resolveAvatarBlobUrl(src, { onUpdate: (updated) => { @@ -48,9 +55,11 @@ export function useStableAvatarSrc(src?: string | null): string | undefined { // cache in the background; deliberately don't swap, to avoid a re-decode // flicker when the bytes are the same. setAvatarSrc(src); - void resolveAvatarBlobUrl(src).catch(() => { - /* Already showing the raw URL; a failed hydration retries next mount. */ - }); + void resolveAvatarBlobUrl(src) + .then(() => preloadAvatarBlobUrl(src)) + .catch(() => { + /* Already showing the raw URL; a failed hydration retries next mount. */ + }); } } diff --git a/packages/components/src/lib/avatar-cache.ts b/packages/components/src/lib/avatar-cache.ts index 0adc508abb..205cf9b80d 100644 --- a/packages/components/src/lib/avatar-cache.ts +++ b/packages/components/src/lib/avatar-cache.ts @@ -31,6 +31,8 @@ type AvatarCacheEntry = { blobUrl: string; blob: Blob; fetchedAt: number; + /** Keep the local image warm for Base UI's synchronous cached-image path. */ + image?: HTMLImageElement; }; const AVATAR_IMAGE_PERSISTENT_CACHE_NAME = 'lody-avatar-image-v2'; @@ -119,6 +121,17 @@ const writePersistedAvatarBlob = async ( } }; +const preloadAvatarImage = (blobUrl: string): HTMLImageElement | undefined => { + if (typeof window === 'undefined' || typeof window.Image !== 'function') return undefined; + + const image = new window.Image(); + image.decoding = 'async'; + image.src = blobUrl; + // Warm only local bytes. Neither cache reads nor first paint wait for decode. + if (typeof image.decode === 'function') void image.decode().catch(() => {}); + return image; +}; + const rememberAvatarBlob = (cacheKey: string, blob: Blob, fetchedAt: number): string => { const previous = avatarMemoryCache.get(cacheKey); if (previous) { @@ -127,7 +140,12 @@ const rememberAvatarBlob = (cacheKey: string, blob: Blob, fetchedAt: number): st } const blobUrl = URL.createObjectURL(blob); - avatarMemoryCache.set(cacheKey, { blobUrl, blob, fetchedAt }); + avatarMemoryCache.set(cacheKey, { + blobUrl, + blob, + fetchedAt, + image: previous?.image ? preloadAvatarImage(blobUrl) : undefined, + }); return blobUrl; }; @@ -232,6 +250,13 @@ export function peekAvatarBlobUrl(image?: string | null): string | null | undefi return avatarMemoryCache.get(getAvatarCacheKey(image))?.blobUrl; } +/** Warm an already cached avatar used by a stable-source surface; never fetch. */ +export function preloadAvatarBlobUrl(image?: string | null): void { + if (image == null) return; + const entry = avatarMemoryCache.get(getAvatarCacheKey(image)); + if (entry && !entry.image) entry.image = preloadAvatarImage(entry.blobUrl); +} + /** * Resolve an avatar URL to a local blob URL, cache-first. Returns the cached * blob (memory → persistent) immediately when present and revalidates the diff --git a/packages/components/tests/cached-avatar-img.test.ts b/packages/components/tests/cached-avatar-img.test.ts index fe84fb700c..7314d2ed5c 100644 --- a/packages/components/tests/cached-avatar-img.test.ts +++ b/packages/components/tests/cached-avatar-img.test.ts @@ -1,11 +1,15 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, createElement } from 'react'; +import { act, createElement, useLayoutEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useCachedAvatarSrc } from '../src/components/cached-avatar-img'; +import { UserAvatar } from '../src/components/user-avatar'; +import { useStableAvatarSrc } from '../src/hooks/use-stable-avatar-src'; import { clearAvatarImageCache, + getAvatarBlobUrl, + peekAvatarBlobUrl, rehydrateAvatarMemoryCacheFromPersistent, } from '../src/lib/avatar-cache'; @@ -46,6 +50,55 @@ function mountProbe(url: string | null | undefined) { const AVATAR_URL = 'https://avatars.githubusercontent.com/loro-dev?size=80'; +// The browser owns when an Image becomes reusable. Drive that boundary through +// explicit load signals; a new Image sees only sources that finished loading. +class ControlledImage { + static loaded = new Set(); + static pending = new Set(); + complete = false; + naturalWidth = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + private source = ''; + private resolveDecode?: () => void; + private rejectDecode?: (error: Error) => void; + + set src(value: string) { + this.source = value; + this.complete = ControlledImage.loaded.has(value); + this.naturalWidth = this.complete ? 128 : 0; + if (!this.complete) ControlledImage.pending.add(this); + } + + get src() { + return this.source; + } + + decode(): Promise { + if (this.complete) return Promise.resolve(); + return new Promise((resolve, reject) => { + this.resolveDecode = resolve; + this.rejectDecode = reject; + }); + } + + static settleLoads(failed = false) { + for (const image of this.pending) { + image.complete = true; + if (failed) { + image.onerror?.(); + image.rejectDecode?.(new Error('invalid image')); + } else { + this.loaded.add(image.src); + image.naturalWidth = 128; + image.onload?.(); + image.resolveDecode?.(); + } + } + this.pending.clear(); + } +} + describe('useCachedAvatarSrc', () => { let fetchMock: ReturnType; @@ -78,6 +131,7 @@ describe('useCachedAvatarSrc', () => { afterEach(() => { clearAvatarImageCache(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -159,4 +213,118 @@ describe('useCachedAvatarSrc', () => { second.root.unmount(); }); }); + + it('warms the local blob without delaying the original image, then paints it on return', async () => { + vi.stubGlobal('Image', ControlledImage); + ControlledImage.loaded = new Set([AVATAR_URL]); + ControlledImage.pending.clear(); + const container = document.createElement('div'); + const root = createRoot(container); + const avatar = (key: string) => + createElement(UserAvatar, { key, user: { name: 'Synthetic User', image: AVATAR_URL } }); + + try { + await act(async () => root.render(avatar('A'))); + // Cache filling and first display finish while blob decode is still held. + const blobUrl = await getAvatarBlobUrl(AVATAR_URL); + expect(container.querySelector('img')?.getAttribute('src')).toBe(AVATAR_URL); + expect(peekAvatarBlobUrl(AVATAR_URL)).toBe(blobUrl); + + await act(async () => ControlledImage.settleLoads()); + await act(async () => root.render(null)); + await act(async () => root.render(avatar('A-return'))); + + expect(container.querySelector('img')?.getAttribute('src')).toBe(blobUrl); + expect(container.querySelector('svg')).toBeNull(); + } finally { + await act(async () => root.unmount()); + } + }); + + it('warms a restored avatar only when used, then paints it immediately on return offline', async () => { + vi.stubGlobal('Image', ControlledImage); + ControlledImage.loaded.clear(); + ControlledImage.pending.clear(); + await getAvatarBlobUrl(AVATAR_URL); + clearAvatarImageCache({ persistent: false }); + await rehydrateAvatarMemoryCacheFromPersistent(); + // Rehydration finishes without waiting on the browser's held decode. + const blobUrl = peekAvatarBlobUrl(AVATAR_URL); + expect(blobUrl).toMatch(/^blob:/); + ControlledImage.settleLoads(); + fetchMock.mockRejectedValue(new Error('offline')); + const container = document.createElement('div'); + const root = createRoot(container); + + try { + await act(async () => + root.render(createElement(UserAvatar, { user: { image: AVATAR_URL } })) + ); + // Restoring bytes alone does not start decoding every stored avatar. + expect(container.querySelector('img')).toBeNull(); + await act(async () => ControlledImage.settleLoads()); + await act(async () => root.render(null)); + await act(async () => + root.render(createElement(UserAvatar, { user: { image: AVATAR_URL } })) + ); + expect(container.querySelector('img')?.getAttribute('src')).toBe(blobUrl); + expect(container.querySelector('svg')).toBeNull(); + } finally { + await act(async () => root.unmount()); + } + }); + + it('keeps the broken-image fallback when background decoding fails', async () => { + vi.stubGlobal('Image', ControlledImage); + ControlledImage.loaded.clear(); + ControlledImage.pending.clear(); + const blobUrl = await getAvatarBlobUrl(AVATAR_URL); + await act(async () => ControlledImage.settleLoads(true)); + expect(await getAvatarBlobUrl(AVATAR_URL)).toBe(blobUrl); + const container = document.createElement('div'); + const root = createRoot(container); + + try { + await act(async () => + root.render(createElement(UserAvatar, { user: { image: AVATAR_URL } })) + ); + await act(async () => ControlledImage.settleLoads(true)); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('svg')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + } + }); + + it('commits the current source immediately when metadata arrives, changes or clears', async () => { + const firstBlob = await getAvatarBlobUrl(AVATAR_URL); + const otherUrl = 'https://example.test/other-avatar.png'; + const otherBlob = await getAvatarBlobUrl(otherUrl); + const commits: Array = []; + function Probe({ src }: { src?: string | null }) { + const resolved = useStableAvatarSrc(src); + useLayoutEffect(() => { + commits.push(resolved); + }); + return createElement('span', { 'data-src': resolved }); + } + const container = document.createElement('div'); + const root = createRoot(container); + + try { + await act(async () => root.render(createElement(Probe))); + for (const [src, expected] of [ + [AVATAR_URL, firstBlob], + [otherUrl, otherBlob], + [null, undefined], + ] as const) { + commits.length = 0; + await act(async () => root.render(createElement(Probe, { src }))); + expect(commits[0]).toBe(expected); + expect(container.querySelector('span')?.getAttribute('data-src')).toBe(expected ?? null); + } + } finally { + await act(async () => root.unmount()); + } + }); }); From 011f7ace7db5d03efbdc44321655dfff62827eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=9C=E7=81=AF?= Date: Tue, 20 Oct 2026 01:33:02 +0800 Subject: [PATCH 2/2] docs: link avatar continuity fix PR Model: gpt-6.1-sol --- .../bug-fix/2026-10-20-message-avatar-switch-continuity.md | 1 + .../bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md index 8bc4fa8eb1..5c4d9ea3e9 100644 --- a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md +++ b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md @@ -2,6 +2,7 @@ Status: proposed Translation: current +PR: https://github.com/LodyAI/Lody/pull/1736 [中文](2026-10-20-message-avatar-switch-continuity.zh.md) diff --git a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md index c65b188373..3ceda353bf 100644 --- a/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md +++ b/.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.zh.md @@ -2,6 +2,7 @@ Status: proposed Translation: current +PR: https://github.com/LodyAI/Lody/pull/1736 [English](2026-10-20-message-avatar-switch-continuity.md)