Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/app/docs/api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export default function ApiDocsPage() {
Pack via <code className="text-teal-300">/api/video/pack</code>, then{' '}
<code className="text-teal-300">/api/workflows/video-to-actions</code>.
</p>
<ol className="mt-4 list-decimal space-y-1 pl-5 text-sm text-ink/60">
<li>Paste a YouTube URL on Home.</li>
<li>Home redirects to <code className="text-teal-300">/studio?video=...</code>.</li>
<li>Studio auto-starts analysis once for that handoff URL.</li>
<li>Transcript and events flow into actions and output publishing.</li>
</ol>
</section>
</main>
<Footer />
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
} from '@/lib/video-to-actions-input';
import {
applyStudioQueryAutoStart,
resetStudioQueryAutoStart,
resolveStudioHandoff,
studioQueryFromSearchParams,
} from '@/lib/studio-handoff';
Expand Down Expand Up @@ -366,6 +367,16 @@
// 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);
Expand Down Expand Up @@ -467,7 +478,7 @@
const started = await startVideoToActions(payload);
if (!started.ok || !started.runId) {
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 481 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 481 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
setMessage(started.error || started.message || 'Could not start Act.');
Expand Down Expand Up @@ -582,7 +593,7 @@
transcript: usableProvidedTranscript(selected?.transcript),
});
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 596 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 596 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
if (!started.ok || !started.runId) {
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/lib/__tests__/studio-handoff.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyStudioQueryAutoStart,
resetStudioQueryAutoStart,
resolveStudioHandoff,
studioQueryFromSearchParams,
studioVideoHref,
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 21 additions & 3 deletions apps/web/src/lib/studio-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,22 @@ export function submitHomePaste(raw: string): string | null {
}

export type StudioQueryStartedKey = { current: string | null };
let strictModeAutoStartedVideoId: string | null = null;
Comment thread
vercel[bot] marked this conversation as resolved.

/**
* 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;
Expand Down Expand Up @@ -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';
}
Loading