fix(dsh-plugin-browserskill): stop observation overlay from flashing between frames

Hold the last ready thumbnail until the next one decodes, and swap the
img src in place instead of remounting through a placeholder.
This commit is contained in:
BB-fat
2026-08-17 07:27:28 +00:00
parent 51085b960b
commit 9854ee2f45
6 changed files with 157 additions and 19 deletions
@@ -92,6 +92,31 @@ describe("ObservationOverlay", () => {
await screen.findByRole("img", { name: "session s1 view" });
});
it("keeps the current frame on stage while the next thumbnail loads", async () => {
let resolveNext: ((url: string) => void) | undefined;
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
h.loadImage.mockImplementation(async (id: string) => {
if (id === "att-2") {
return new Promise<string>((resolve) => {
resolveNext = resolve;
});
}
return `blob:${id}`;
});
render(<ObservationOverlay store={h.store} />);
const img = await screen.findByRole("img", { name: "session s1 view" });
expect(img.getAttribute("src")).toBe("blob:att-1");
h.push([{ ...BUSY, thumbnailAttachmentId: "att-2" }]);
await waitFor(() => expect(h.loadImage).toHaveBeenCalledWith("att-2"));
// Same <img> node, same src — no placeholder flash, no remount/fade.
expect(screen.queryByText("waiting for page")).toBeNull();
expect(screen.getByRole("img", { name: "session s1 view" })).toBe(img);
expect(img.getAttribute("src")).toBe("blob:att-1");
resolveNext?.("blob:att-2");
await waitFor(() => expect(img.getAttribute("src")).toBe("blob:att-2"));
expect(screen.getByRole("img", { name: "session s1 view" })).toBe(img);
});
it("interrupt: disabled while idle, active call posts and shows progress", async () => {
const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]);
render(<ObservationOverlay store={h.store} />);
@@ -119,7 +119,7 @@ describe("thumbnail blob URL lifecycle", () => {
return revoke;
}
it("replacing a frame revokes the old blob URL and drops its entry", async () => {
it("holds the last ready frame until the replacement decodes", async () => {
const revoke = withRevokeSpy();
const { store, esInstances } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
@@ -132,11 +132,50 @@ describe("thumbnail blob URL lifecycle", () => {
type: "upsert",
session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" },
});
// Old blob stays painted so the overlay does not flash a placeholder.
expect(store.getSnapshot().thumbnails["a1"]).toEqual({
status: "ready",
url: "blob:url-a1",
});
expect(store.getSnapshot().displayFrames.s1).toEqual({
status: "ready",
url: "blob:url-a1",
});
expect(revoke).not.toHaveBeenCalled();
store.ensureThumbnail("a2");
await vi.waitFor(() =>
expect(store.getSnapshot().displayFrames.s1).toEqual({
status: "ready",
url: "blob:url-a2",
}),
);
expect(store.getSnapshot().thumbnails["a1"]).toBeUndefined();
expect(revoke).toHaveBeenCalledWith("blob:url-a1");
store.stop();
});
it("keeps the last good frame in displayFrames when the next load fails", async () => {
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
store.ensureThumbnail("a1");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["a1"].status).toBe("ready"));
loadImage.mockRejectedValueOnce(new Error("nope"));
emit(esInstances[0], {
type: "upsert",
session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" },
});
store.ensureThumbnail("a2");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["a2"]?.status).toBe("error"));
expect(store.getSnapshot().displayFrames.s1).toEqual({
status: "error",
url: "blob:url-a1",
});
store.stop();
});
it("remove and reset revoke the session's tracked frame", async () => {
const revoke = withRevokeSpy();
const { store, esInstances } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);