fix(timeline): reserve inline image height from imeta dim to stop load thrash

Root cause of the first-load flicker tho saw: the inline message `<img>` had no
reserved height — it painted at height 0 and grew to its intrinsic size when the
bytes arrived. In the virtualized list every late image bumps the measured row
height, shifts its neighbors, and re-measures the virtualizer: a cascade of
reflows that flickers and thrashes while a media-heavy channel loads, and fights
the first-load stick-to-bottom because the document height keeps moving.

The stick-to-bottom settle-pin (6f6d2320) held the viewport THROUGH the growth
but did not stop the per-row reflow — this kills the growth at the source.

Fix: reserve the image's rendered box up front from the imeta `dim`
(WIDTHxHEIGHT), which is already parsed and was already used for video aspect
ratios. `reservedImageSize` scales dim to the display caps (max-w-sm/max-h-64 →
384×256, object-contain fit) and the `<img>` sets those as width/height
attributes, so the row's height is stable before load. `h-auto` keeps it
responsive under the width cap while the attributes establish the aspect ratio.
Falls back to natural load when `dim` is absent (can't reserve what we don't
know). The helper lives in markdownUtils.ts (pure, exported) so it's unit-tested
directly via the DOM lane.

Main timeline only; thread pane untouched. 5 DOM tests cover the dim→box math
(within caps, width-capped, height-capped, never-exceeds, malformed fallback).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 22:06:31 -07:00
co-authored by Taylor Ho
parent 6f6d2320ed
commit 01b292c5ef
3 changed files with 107 additions and 2 deletions
+19 -2
View File
@@ -51,6 +51,7 @@ import {
classifyChildren,
hasBlockMedia,
isImageOnlyParagraph,
reservedImageSize,
shallowArrayEqual,
} from "./markdownUtils";
import { resolveFileCard } from "./markdownFileCard";
@@ -246,10 +247,12 @@ type MarkdownVariant = "default" | "compact" | "tight";
*/
function ImageBlock({
alt,
dim,
resolvedSrc,
src,
}: {
alt: string | undefined;
dim?: string;
resolvedSrc: string | undefined;
src: string | undefined;
}) {
@@ -299,13 +302,22 @@ function ImageBlock({
});
};
const reserved = reservedImageSize(dim);
return (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */}
<img
alt={alt}
className="mt-1 block max-h-64 max-w-sm cursor-pointer rounded-xl object-contain"
className="mt-1 block h-auto max-h-64 max-w-sm cursor-pointer rounded-xl object-contain"
// Reserve the rendered box from imeta `dim` so the row's height is
// stable BEFORE the image loads — no zero-then-grow reflow thrashing the
// virtualized list. width/height stay within the max-h-64/max-w-sm caps
// (reservedImageSize pre-scales to them), so the caps never shrink it
// further. Falls back to natural load when `dim` is absent.
height={reserved?.height}
src={resolvedSrc}
width={reserved?.width}
onClick={() => setLightboxOpen(true)}
onContextMenuCapture={handleContextMenu}
/>
@@ -911,7 +923,12 @@ function createMarkdownComponents(
}
return (
<span data-block-media="" className="block">
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
<ImageBlock
alt={alt}
dim={src ? imetaByUrl?.get(src)?.dim : undefined}
resolvedSrc={resolvedSrc}
src={src}
/>
</span>
);
},
+43
View File
@@ -65,3 +65,46 @@ export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
}
return true;
}
// Display caps for inline message images — must mirror the `max-h-64 max-w-sm`
// (256×384px) Tailwind classes on the rendered <img>.
export const INLINE_IMAGE_MAX_WIDTH = 384;
export const INLINE_IMAGE_MAX_HEIGHT = 256;
/**
* Compute the exact rendered box for an inline image from its imeta `dim`
* ("WIDTHxHEIGHT"), scaled to fit the display caps (same fit as `object-contain`
* within `max-w-sm`/`max-h-64`). Used to reserve the row's height via the <img>
* width/height attributes BEFORE the bytes load — without it, image rows paint
* at height 0 then grow when the image arrives, thrashing the virtualized list
* (every late image re-measures and shifts its neighbors).
*
* Returns undefined when `dim` is missing/malformed/non-positive — we can't
* reserve what we don't know, so the natural load is the correct fallback.
*/
export function reservedImageSize(
dim: string | undefined,
): { width: number; height: number } | undefined {
if (!dim) return undefined;
const match = dim.match(/^(\d+)x(\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
const scale = Math.min(
1,
INLINE_IMAGE_MAX_WIDTH / width,
INLINE_IMAGE_MAX_HEIGHT / height,
);
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
};
}
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { test } from "node:test";
// Imported through the DOM lane (esbuild) so we can pull from markdownUtils.ts
// directly even though it imports React — no stale inlined copy. Pure-math
// helper, but it lives in a React module, hence the .dom lane.
import {
INLINE_IMAGE_MAX_HEIGHT,
INLINE_IMAGE_MAX_WIDTH,
reservedImageSize,
} from "./markdownUtils";
test("returns undefined when dim is missing or malformed", () => {
assert.equal(reservedImageSize(undefined), undefined);
assert.equal(reservedImageSize(""), undefined);
assert.equal(reservedImageSize("800"), undefined);
assert.equal(reservedImageSize("800x"), undefined);
assert.equal(reservedImageSize("axb"), undefined);
assert.equal(reservedImageSize("0x600"), undefined);
assert.equal(reservedImageSize("800x0"), undefined);
});
test("small image within caps is reserved at its natural size", () => {
// 200x100 fits inside 384x256 → no scaling.
assert.deepEqual(reservedImageSize("200x100"), { width: 200, height: 100 });
});
test("wide image scales down to the width cap, preserving aspect ratio", () => {
// 800x400 (2:1). Width cap 384 → scale 0.48 → 384x192.
assert.deepEqual(reservedImageSize("800x400"), { width: 384, height: 192 });
});
test("tall image scales down to the height cap, preserving aspect ratio", () => {
// 400x800 (1:2). Height cap 256 → scale 0.32 → 128x256.
assert.deepEqual(reservedImageSize("400x800"), { width: 128, height: 256 });
});
test("reserved box never exceeds the display caps", () => {
const r = reservedImageSize("4000x3000");
assert.ok(r);
assert.ok(r.width <= INLINE_IMAGE_MAX_WIDTH);
assert.ok(r.height <= INLINE_IMAGE_MAX_HEIGHT);
// 4:3 → constrained by height (256) → 341x256.
assert.deepEqual(r, { width: 341, height: 256 });
});