mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +00:00
fix(studio): release retained preview resources (#2924)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user