fix(app): anchor image growth during timeline reload
Intrinsic image dimensions can arrive after the timeline has painted, outside the React segment-change path. Let the browser preserve the visible content anchor during that resize instead of exposing a frame before the deferred bottom correction.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { test as base } from "../support/fixtures";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
createNearTenMegabyteAssistantPng,
|
||||
@@ -45,6 +45,131 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Reload geometry image",
|
||||
fileName: "reload-geometry-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
|
||||
await page.addInitScript(
|
||||
({ accessibleName }) => {
|
||||
interface ImageGeometryEvent {
|
||||
atMs: number;
|
||||
complete: boolean;
|
||||
height: number;
|
||||
naturalHeight: number;
|
||||
naturalWidth: number;
|
||||
scrollTop: number;
|
||||
width: number;
|
||||
}
|
||||
const state = {
|
||||
accessibleName,
|
||||
events: [] as ImageGeometryEvent[],
|
||||
mutationObserver: null as MutationObserver | null,
|
||||
resizeObserver: null as ResizeObserver | null,
|
||||
target: null as Element | null,
|
||||
};
|
||||
const record = () => {
|
||||
const target = state.target;
|
||||
if (!target) return;
|
||||
const imageElement =
|
||||
target instanceof HTMLImageElement ? target : target.querySelector("img");
|
||||
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const event: ImageGeometryEvent = {
|
||||
atMs: performance.now(),
|
||||
complete: imageElement?.complete ?? false,
|
||||
height: rect.height,
|
||||
naturalHeight: imageElement?.naturalHeight ?? 0,
|
||||
naturalWidth: imageElement?.naturalWidth ?? 0,
|
||||
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
|
||||
width: rect.width,
|
||||
};
|
||||
const previous = state.events.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
previous.complete !== event.complete ||
|
||||
previous.height !== event.height ||
|
||||
previous.naturalHeight !== event.naturalHeight ||
|
||||
previous.naturalWidth !== event.naturalWidth ||
|
||||
previous.scrollTop !== event.scrollTop ||
|
||||
previous.width !== event.width
|
||||
) {
|
||||
state.events.push(event);
|
||||
}
|
||||
};
|
||||
const inspect = () => {
|
||||
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName,
|
||||
);
|
||||
if (!target || target === state.target) {
|
||||
record();
|
||||
return;
|
||||
}
|
||||
state.target = target;
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(record);
|
||||
state.resizeObserver.observe(target);
|
||||
record();
|
||||
};
|
||||
const start = () => {
|
||||
state.mutationObserver = new MutationObserver(inspect);
|
||||
state.mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["src", "style"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
inspect();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: typeof state;
|
||||
}
|
||||
).__paseoReloadImageGeometry = state;
|
||||
},
|
||||
{ accessibleName: image.alt },
|
||||
);
|
||||
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.reload();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: { events: Array<unknown> };
|
||||
}
|
||||
).__paseoReloadImageGeometry;
|
||||
if (!state) throw new Error("Reload image geometry observer was not installed");
|
||||
return state.events;
|
||||
});
|
||||
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
|
||||
const initialGeometry = geometryEvents.find((event) => event.height > 0);
|
||||
if (!initialGeometry)
|
||||
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
|
||||
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
|
||||
if (!expandedGeometry)
|
||||
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
|
||||
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
|
||||
initialGeometry.scrollTop,
|
||||
);
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -1031,7 +1031,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
minHeight: 0,
|
||||
overflowX: "hidden",
|
||||
overflowY: scrollEnabled ? "auto" : "hidden",
|
||||
overflowAnchor: "none",
|
||||
overscrollBehaviorY: "contain",
|
||||
};
|
||||
}, [scrollEnabled]);
|
||||
|
||||
Reference in New Issue
Block a user