fix(renderer): anchor the reader from image mount, not image load

A decoder sizes an image from its header, so the row grows as soon as the
first bytes are parsed -- measured here at 421ms against a load event at
2407ms. Marking the row only once load fired left that first and largest
growth outside the compensation window, and scrolling back through history
still moved the reader by the full image height.

Rows are now marked from the moment an image mounts until loading finishes,
counted so a message with several images stays marked until the last one is
done. The listener also moves to the document: rows announce their images
while mounting, before a ref-driven listener on the timeline element would
exist to hear the first one, which is why the mount signal was being lost.

The fixture serves a real PNG and holds the response open, so the row grows
long before load the way a large image does; an SVG served in one shot lays
out and fires load together and cannot reproduce this. A companion assertion
checks the fixture still has that gap, so it cannot quietly stop testing the
timing it exists to test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-08-03 03:47:16 +08:00
co-authored by Claude Opus 5
parent b72e0fcaf3
commit 38a8529d0c
4 changed files with 270 additions and 85 deletions
@@ -1,6 +1,9 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { SESSION_IMAGE_SETTLED_EVENT } from '../session-image-contract.js';
import { computed, onMounted, ref, watch } from 'vue';
import {
SESSION_IMAGE_PENDING_EVENT,
SESSION_IMAGE_SETTLED_EVENT,
} from '../session-image-contract.js';
defineOptions({ name: 'SessionImage' });
@@ -13,36 +16,43 @@ const props = defineProps({
title: { type: String, default: '' },
});
const figure = ref(null);
const status = ref(props.src ? 'loading' : 'error');
const accessibleLabel = computed(() => props.alt || props.title || 'Session image');
watch(() => props.src, source => {
status.value = source ? 'loading' : 'error';
// The events are composed so they leave the shadow root, and dispatched
// synchronously so the timeline knows the row's size is about to change for a
// reason the reader did not cause -- before the resize observation lands.
function announce(target, type) {
target?.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true }));
}
// Announced from mount rather than from load: the row grows as soon as the
// decoder knows the intrinsic size, which for anything bigger than a trivial
// image is long before loading finishes.
onMounted(() => {
if (props.src) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
});
// Announce synchronously, before the resize observation this growth triggers,
// so the timeline already knows the row is about to change size for a reason
// the reader did not cause.
function announceSettled(event) {
event.target?.dispatchEvent(new CustomEvent(SESSION_IMAGE_SETTLED_EVENT, {
bubbles: true,
composed: true,
}));
}
watch(() => props.src, source => {
status.value = source ? 'loading' : 'error';
if (source) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
});
function handleLoad(event) {
status.value = 'loaded';
announceSettled(event);
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
}
function handleError(event) {
status.value = 'error';
announceSettled(event);
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
}
</script>
<template>
<figure
ref="figure"
class="session-image"
:class="`is-${status}`"
:aria-busy="status === 'loading' ? 'true' : undefined"
@@ -4,7 +4,13 @@
export const SESSION_IMAGE_TAG = 'obelisk-session-image';
// Fired from inside the session image element (composed, so it crosses the
// shadow boundary) once the image has either decoded or failed. The virtualized
// timeline uses it to tell real media growth apart from an estimate correction.
// Fired from inside the session image element (composed, so they cross the
// shadow boundary). The virtualized timeline uses them to tell real media
// growth apart from an estimate correction.
//
// Pending is announced when the element mounts with a source, not when the
// image loads: a decoder sizes an image from its header and grows the row well
// before the load event, so a row is only known to be settled once loading has
// actually finished.
export const SESSION_IMAGE_PENDING_EVENT = 'obelisk-session-image-pending';
export const SESSION_IMAGE_SETTLED_EVENT = 'obelisk-session-image-settled';
@@ -1,4 +1,4 @@
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onScopeDispose, ref } from 'vue';
import {
defaultRangeExtractor,
elementScroll,
@@ -6,10 +6,14 @@ import {
useVirtualizer,
} from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
import { SESSION_IMAGE_SETTLED_EVENT } from './session-image-contract.js';
import {
SESSION_IMAGE_PENDING_EVENT,
SESSION_IMAGE_SETTLED_EVENT,
} from './session-image-contract.js';
// How long after an image settles its row still counts as "grew because media
// finished" rather than "grew because the estimate was wrong".
// finished" rather than "grew because the estimate was wrong". Covers the
// remeasurement that follows the final state change.
const MEDIA_SETTLE_WINDOW_MS = 1_000;
function estimatedTextHeight(text = '') {
@@ -134,24 +138,50 @@ export function useSessionTimelineViewport({
// virtual-core deliberately skips scroll compensation when an already-measured
// row above the viewport is re-measured during an upward scroll, because that
// is normally an estimate correction and compensating it makes rows jump while
// the reader scrolls back. An image finishing is not an estimate correction:
// the row really did get taller, so leaving it uncompensated pushes everything
// the reader is looking at down the screen. Track which rows just settled
// media and compensate only those.
// the reader scrolls back. Media growth is not an estimate correction: the row
// really did get taller, so leaving it uncompensated pushes everything the
// reader is looking at down the screen.
//
// A row counts as settling from the moment an image mounts until loading
// finishes, because a decoder grows the row from the image header long before
// the load event -- keying off load alone would miss the first and largest
// growth. Counted, not flagged, so a message with several images stays marked
// until the last of them is done.
const mediaPending = new Map();
const mediaSettledAt = new Map();
function noteMediaSettled(event) {
function rowKeyFor(event) {
const row = event.target?.closest?.('.virtual-timeline-row');
const index = Number(row?.dataset?.index);
if (!Number.isInteger(index)) return;
if (!row) return null;
const timeline = timelineElement?.value;
if (timeline && !timeline.contains(row)) return null;
const index = Number(row.dataset?.index);
if (!Number.isInteger(index)) return null;
return items.value[index]?.key ?? index;
}
function noteMediaPending(event) {
const key = rowKeyFor(event);
if (key === null) return;
mediaPending.set(key, (mediaPending.get(key) ?? 0) + 1);
mediaSettledAt.delete(key);
}
function noteMediaSettled(event) {
const key = rowKeyFor(event);
if (key === null) return;
const outstanding = (mediaPending.get(key) ?? 0) - 1;
if (outstanding > 0) mediaPending.set(key, outstanding);
else mediaPending.delete(key);
const now = performance.now();
for (const [key, at] of mediaSettledAt) {
if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(key);
for (const [settledKey, at] of mediaSettledAt) {
if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(settledKey);
}
mediaSettledAt.set(items.value[index]?.key ?? index, now);
mediaSettledAt.set(key, now);
}
function isMediaSettling(key) {
if (mediaPending.has(key)) return true;
const at = mediaSettledAt.get(key);
if (at === undefined) return false;
if (performance.now() - at > MEDIA_SETTLE_WINDOW_MS) {
@@ -161,14 +191,16 @@ export function useSessionTimelineViewport({
return true;
}
watch(
() => timelineElement?.value,
(element, previous) => {
previous?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
element?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
},
{ immediate: true },
);
// Bound to the document rather than to the timeline element, because rows
// announce their images while mounting -- before a ref-driven listener would
// be in place to hear the first one.
const eventTarget = globalThis.document ?? null;
eventTarget?.addEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
eventTarget?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
onScopeDispose(() => {
eventTarget?.removeEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
eventTarget?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
});
virtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (item.start >= instance.getScrollOffset() + instance.scrollAdjustments) return false;