diff --git a/apps/web/src/app/docs/api/page.tsx b/apps/web/src/app/docs/api/page.tsx
index deb5ce6a4..b359ffe68 100644
--- a/apps/web/src/app/docs/api/page.tsx
+++ b/apps/web/src/app/docs/api/page.tsx
@@ -110,6 +110,12 @@ export default function ApiDocsPage() {
Pack via /api/video/pack, then{' '}
/api/workflows/video-to-actions.
+
+ - Paste a YouTube URL on Home.
+ - Home redirects to
/studio?video=....
+ - Studio auto-starts analysis once for that handoff URL.
+ - Transcript and events flow into actions and output publishing.
+
diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx
index 090f81f03..975dd032d 100644
--- a/apps/web/src/components/OneLoopStudio.tsx
+++ b/apps/web/src/components/OneLoopStudio.tsx
@@ -62,6 +62,7 @@ import {
} from '@/lib/video-to-actions-input';
import {
applyStudioQueryAutoStart,
+ resetStudioQueryAutoStart,
resolveStudioHandoff,
studioQueryFromSearchParams,
} from '@/lib/studio-handoff';
@@ -366,6 +367,16 @@ export default function OneLoopStudio({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
+ useEffect(() => {
+ // Release the module-level Strict Mode guard when Studio truly unmounts so
+ // re-entering /studio?video= with the same id (re-paste, or retry after a
+ // failed run) auto-starts again. Deferred so React's synchronous Strict
+ // Mode unmount/remount still sees the guard and does not double-start.
+ return () => {
+ window.setTimeout(() => resetStudioQueryAutoStart(), 0);
+ };
+ }, []);
+
const analyze = (event?: FormEvent) => {
event?.preventDefault();
void runAnalysis(url);
diff --git a/apps/web/src/lib/__tests__/studio-handoff.test.ts b/apps/web/src/lib/__tests__/studio-handoff.test.ts
index 995c9aa4b..b28482fb3 100644
--- a/apps/web/src/lib/__tests__/studio-handoff.test.ts
+++ b/apps/web/src/lib/__tests__/studio-handoff.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyStudioQueryAutoStart,
+ resetStudioQueryAutoStart,
resolveStudioHandoff,
studioQueryFromSearchParams,
studioVideoHref,
@@ -72,6 +73,55 @@ describe('submitHomePaste kicks pack emit then hands off to Studio', () => {
});
describe('applyStudioQueryAutoStart (?video= one-shot, Strict Mode safe)', () => {
+ afterEach(() => {
+ // Release the module-level Strict Mode guard so tests stay isolated.
+ resetStudioQueryAutoStart();
+ });
+
+ it('re-starts the same video after a genuine unmount clears the guard', () => {
+ const start = vi.fn();
+ const firstMountKey = { current: null as string | null };
+ const first = applyStudioQueryAutoStart({
+ query: FIXTURE_WATCH,
+ startedKey: firstMountKey,
+ start,
+ });
+ expect(first).toBe('started');
+
+ // Genuine unmount releases the module-level guard.
+ resetStudioQueryAutoStart();
+
+ // Fresh mount (fresh ref) re-navigates to the same video.
+ const remountKey = { current: null as string | null };
+ const remount = applyStudioQueryAutoStart({
+ query: FIXTURE_WATCH,
+ startedKey: remountKey,
+ start,
+ });
+ expect(remount).toBe('started');
+ expect(start).toHaveBeenCalledTimes(2);
+ });
+
+ it('suppresses duplicate start when a Strict Mode remount gets a fresh ref object', () => {
+ const remountWatchUrl = 'https://www.youtube.com/watch?v=pBsT6v-ciO8';
+ const firstMountKey = { current: null as string | null };
+ const remountKey = { current: null as string | null };
+ const start = vi.fn();
+ const first = applyStudioQueryAutoStart({
+ query: remountWatchUrl,
+ startedKey: firstMountKey,
+ start,
+ });
+ const remount = applyStudioQueryAutoStart({
+ query: remountWatchUrl,
+ startedKey: remountKey,
+ start,
+ });
+ expect(first).toBe('started');
+ expect(remount).toBe('already');
+ expect(start).toHaveBeenCalledTimes(1);
+ });
+
it('starts once with the canonical watch URL across a Strict Mode double effect', () => {
const startedKey = { current: null as string | null };
const start = vi.fn();
diff --git a/apps/web/src/lib/studio-handoff.ts b/apps/web/src/lib/studio-handoff.ts
index 08be98249..71b2f4f6a 100644
--- a/apps/web/src/lib/studio-handoff.ts
+++ b/apps/web/src/lib/studio-handoff.ts
@@ -43,10 +43,22 @@ export function submitHomePaste(raw: string): string | null {
}
export type StudioQueryStartedKey = { current: string | null };
+let strictModeAutoStartedVideoId: string | null = null;
/**
- * One-shot ?video= / ?url= kick. Safe under Strict Mode: the same startedKey
- * ref suppresses a second start() for the same video id.
+ * Clear the module-level Strict Mode auto-start guard. Studio calls this on a
+ * genuine unmount so re-navigating to the same ?video= later in the same SPA
+ * session (or retrying after a failed run) can auto-start again. The guard only
+ * exists to swallow React's synchronous Strict Mode double-mount, so it must be
+ * released once the component truly leaves the tree.
+ */
+export function resetStudioQueryAutoStart(): void {
+ strictModeAutoStartedVideoId = null;
+}
+
+/**
+ * One-shot ?video= / ?url= kick. Safe under Strict Mode remounts: both the
+ * caller ref and a module-level key suppress duplicate start() calls.
*/
export type StudioSearchParams = {
get(name: string): string | null;
@@ -94,8 +106,14 @@ export function applyStudioQueryAutoStart(input: {
return 'invalid';
}
input.onResolved?.(handoff.watchUrl);
- if (input.startedKey.current === handoff.videoId) return 'already';
+ if (
+ input.startedKey.current === handoff.videoId ||
+ strictModeAutoStartedVideoId === handoff.videoId
+ ) {
+ return 'already';
+ }
input.startedKey.current = handoff.videoId;
+ strictModeAutoStartedVideoId = handoff.videoId;
input.start(handoff.watchUrl);
return 'started';
}