fix(studio): release retained preview resources (#2924)

This commit is contained in:
Miguel Ángel
2026-07-31 14:31:43 +02:00
committed by GitHub
parent a3c8f897f2
commit fbfffb1aa7
6 changed files with 308 additions and 92 deletions
@@ -1,5 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
class MockResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}
class MockImage {
static instances: MockImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 0;
naturalHeight = 0;
src = "";
constructor() {
MockImage.instances.push(this);
}
}
const originalResizeObserver = globalThis.ResizeObserver;
const originalImage = globalThis.Image;
let host: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.Image = MockImage as unknown as typeof Image;
MockImage.instances = [];
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
globalThis.ResizeObserver = originalResizeObserver;
globalThis.Image = originalImage;
document.body.replaceChildren();
});
describe("buildCompositionThumbnailUrl", () => {
it("includes selector and occurrence index for precise element thumbnails", () => {
@@ -17,3 +66,48 @@ describe("buildCompositionThumbnailUrl", () => {
);
});
});
describe("CompositionThumbnail", () => {
function renderThumbnail(): MockImage {
root = createRoot(host);
act(() => {
root!.render(
React.createElement(CompositionThumbnail, {
previewUrl: "/api/projects/demo/preview",
label: "",
labelColor: "#fff",
}),
);
});
const probe = MockImage.instances[0];
if (!probe) throw new Error("Expected an image probe");
return probe;
}
it("renders visible tiles after the off-DOM probe loads", () => {
const probe = renderThumbnail();
act(() => {
probe.naturalWidth = 1920;
probe.naturalHeight = 1080;
probe.onload?.();
});
const tiles = [...host.querySelectorAll("img")];
expect(tiles.length).toBeGreaterThan(0);
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
});
it("aborts its off-DOM image probe when unmounted", () => {
const probe = renderThumbnail();
expect(host.querySelector("img")).toBeNull();
expect(probe.src).toContain("/api/projects/demo/thumbnail/index.html");
act(() => root?.unmount());
root = null;
expect(probe.onload).toBeNull();
expect(probe.onerror).toBeNull();
expect(probe.src).toBe("");
});
});
@@ -1,4 +1,4 @@
import { memo, useCallback, useState, useRef } from "react";
import { memo, useCallback, useEffect, useState, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
interface CompositionThumbnailProps {
@@ -88,26 +88,39 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
selectorIndex,
origin: window.location.origin,
});
// Probe outside React's DOM and explicitly abort on URL changes/unmount. A
// hidden React <img> keeps its Fiber reachable from Blink's pending-activity
// queue while a request is unresolved.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
let cancelled = false;
setLoaded(false);
const probe = new Image();
probe.onload = () => {
if (cancelled) return;
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
setAspect(probe.naturalWidth / probe.naturalHeight);
}
setLoaded(true);
};
probe.onerror = () => {
if (!cancelled) setLoaded(false);
};
probe.src = url;
return () => {
cancelled = true;
probe.onload = null;
probe.onerror = null;
probe.src = "";
};
}, [url]);
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
return (
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
<img
src={url}
alt=""
draggable={false}
loading="eager"
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setAspect(img.naturalWidth / img.naturalHeight);
}
setLoaded(true);
}}
className="hidden"
/>
{loaded && (
<div
className="absolute inset-0 flex"