mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
6f6d2320ed
commit
01b292c5ef
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user