diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index f2a9313e6d..4f915743f8 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -333,7 +333,7 @@ export function StudioApp() {
: null,
effectiveTimelineDuration,
});
- const compositionDimensions = useCompositionDimensions();
+ const compositionDimensions = useCompositionDimensions(previewIframeRef);
const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
projectId,
refreshKey,
diff --git a/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx b/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx
new file mode 100644
index 0000000000..4f62397722
--- /dev/null
+++ b/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx
@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+import { CaptionOverlay } from "./CaptionOverlay";
+import { useCaptionStore } from "../store";
+
+Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
+
+it("only schedules caption layout for messages from its preview", () => {
+ const host = document.createElement("div");
+ const preview = document.createElement("iframe");
+ const foreign = document.createElement("iframe");
+ document.body.append(host, preview, foreign);
+ const root = createRoot(host);
+ const schedule = vi.fn(() => 1);
+ vi.stubGlobal("requestAnimationFrame", schedule);
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ useCaptionStore.getState().setEditMode(true);
+ const send = (source: MessageEventSource | null) =>
+ act(() => {
+ window.dispatchEvent(new MessageEvent("message", { source, data: { source: "hf-preview" } }));
+ });
+ try {
+ act(() => root.render());
+ schedule.mockClear();
+ send(foreign.contentWindow);
+ send(null);
+ expect(schedule).not.toHaveBeenCalled();
+ send(preview.contentWindow);
+ expect(schedule).toHaveBeenCalledOnce();
+ } finally {
+ act(() => root.unmount());
+ useCaptionStore.getState().reset();
+ host.remove();
+ preview.remove();
+ foreign.remove();
+ vi.unstubAllGlobals();
+ }
+});
diff --git a/packages/studio/src/captions/components/CaptionOverlay.tsx b/packages/studio/src/captions/components/CaptionOverlay.tsx
index 5681b25a26..cf1da7af16 100644
--- a/packages/studio/src/captions/components/CaptionOverlay.tsx
+++ b/packages/studio/src/captions/components/CaptionOverlay.tsx
@@ -116,6 +116,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
// messages, element resize, window resize.
const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
const handleMessage = (e: MessageEvent) => {
+ if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
const data = e.data;
if (data?.source === "hf-preview") scheduleTick();
};
diff --git a/packages/studio/src/components/nle/PreviewOverlays.tsx b/packages/studio/src/components/nle/PreviewOverlays.tsx
index 5bbbbcd6dd..05c8a06ca6 100644
--- a/packages/studio/src/components/nle/PreviewOverlays.tsx
+++ b/packages/studio/src/components/nle/PreviewOverlays.tsx
@@ -139,7 +139,7 @@ export function PreviewOverlays({
}: PreviewOverlaysProps) {
const { activeCompPath, previewIframeRef } = useStudioShellContext();
const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
- const compositionDimensions = useCompositionDimensions();
+ const compositionDimensions = useCompositionDimensions(previewIframeRef);
// Caption edit mode is entered automatically when captions are detected;
// these give the author an explicit way OUT (and back in). Without them the
diff --git a/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx b/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx
new file mode 100644
index 0000000000..36ef06c446
--- /dev/null
+++ b/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx
@@ -0,0 +1,54 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+import { useCaptionDetection } from "./useCaptionDetection";
+
+Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
+
+it("does not let a foreign window trigger caption activation", () => {
+ const host = document.createElement("div");
+ const preview = document.createElement("iframe");
+ const foreign = document.createElement("iframe");
+ document.body.append(host, preview, foreign);
+ const root = createRoot(host);
+ const fetchMock = vi.fn(() => new Promise(() => {}));
+ vi.stubGlobal("fetch", fetchMock);
+ const params = {
+ projectId: "demo",
+ activeCompPath: "captions.html",
+ compIdToSrc: new Map(),
+ captionEditMode: false,
+ captionHasSelection: false,
+ previewIframeRef: { current: preview },
+ captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
+ setRightCollapsed: vi.fn(),
+ };
+ function Harness() {
+ useCaptionDetection(params);
+ return null;
+ }
+ const send = (source: MessageEventSource | null) =>
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent("message", { source, data: { source: "hf-preview", type: "state" } }),
+ );
+ });
+ try {
+ act(() => root.render());
+ const group = preview.contentDocument!.createElement("div");
+ group.className = "caption-group";
+ preview.contentDocument!.body.append(group);
+ send(foreign.contentWindow);
+ send(null);
+ expect(fetchMock).not.toHaveBeenCalled();
+ send(preview.contentWindow);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ } finally {
+ act(() => root.unmount());
+ host.remove();
+ preview.remove();
+ foreign.remove();
+ vi.unstubAllGlobals();
+ }
+});
diff --git a/packages/studio/src/hooks/useCaptionDetection.ts b/packages/studio/src/hooks/useCaptionDetection.ts
index e7fce63c47..b7554b174f 100644
--- a/packages/studio/src/hooks/useCaptionDetection.ts
+++ b/packages/studio/src/hooks/useCaptionDetection.ts
@@ -131,6 +131,7 @@ export function useCaptionDetection({
};
const handleMessage = (e: MessageEvent) => {
+ if (!e.source || e.source !== previewIframeRef.current?.contentWindow) return;
const data = e.data;
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
if (!acceptStudioRuntimeMessage(data)) return;
diff --git a/packages/studio/src/hooks/useCompositionDimensions.test.tsx b/packages/studio/src/hooks/useCompositionDimensions.test.tsx
new file mode 100644
index 0000000000..ec02a8f6c5
--- /dev/null
+++ b/packages/studio/src/hooks/useCompositionDimensions.test.tsx
@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it } from "vitest";
+import { useCompositionDimensions } from "./useCompositionDimensions";
+
+Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
+
+it("accepts only the current preview frame and rejects foreign or missing senders", () => {
+ const host = document.createElement("div");
+ const preview = document.createElement("iframe");
+ const foreign = document.createElement("iframe");
+ document.body.append(host, preview, foreign);
+ const ref = { current: preview };
+ const root = createRoot(host);
+ function Harness() {
+ return JSON.stringify(useCompositionDimensions(ref));
+ }
+ const send = (source: MessageEventSource | null, width: number) =>
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ source,
+ origin: window.location.origin,
+ data: { source: "hf-preview", type: "stage-size", width, height: 100 },
+ }),
+ );
+ });
+ try {
+ act(() => root.render());
+ send(foreign.contentWindow, 300);
+ send(null, 400);
+ expect(host.textContent).toBe("null");
+ send(preview.contentWindow, 200);
+ expect(host.textContent).toBe('{"width":200,"height":100}');
+ ref.current = foreign;
+ send(preview.contentWindow, 500);
+ expect(host.textContent).toBe('{"width":200,"height":100}');
+ send(foreign.contentWindow, 600);
+ expect(host.textContent).toBe('{"width":600,"height":100}');
+ } finally {
+ act(() => root.unmount());
+ host.remove();
+ preview.remove();
+ foreign.remove();
+ }
+});
diff --git a/packages/studio/src/hooks/useCompositionDimensions.ts b/packages/studio/src/hooks/useCompositionDimensions.ts
index 49a5371496..da83c2a2ac 100644
--- a/packages/studio/src/hooks/useCompositionDimensions.ts
+++ b/packages/studio/src/hooks/useCompositionDimensions.ts
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, type RefObject } from "react";
import { useMountEffect } from "./useMountEffect";
import type { CompositionDimensions } from "../components/renders/RenderQueue";
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -29,13 +29,14 @@ function readPositiveDimensions(width: unknown, height: unknown): CompositionDim
return { width: parsedWidth, height: parsedHeight };
}
-export function useCompositionDimensions() {
+export function useCompositionDimensions(iframeRef: RefObject) {
const [compositionDimensions, setCompositionDimensions] = useState(
null,
);
useMountEffect(() => {
const handleMessage = (e: MessageEvent) => {
+ if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
const dimensions = readCompositionSizeMessage(e.data);
if (!dimensions) return;
setCompositionDimensions((prev) =>