From 246a1911b17f8b04e41bc2620efc80823c15c5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 22:39:14 +0200 Subject: [PATCH] fix(studio): auto-reconnect when preview server is not running (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): auto-reconnect when preview server is not running When the preview server is not reachable (tab reload after server died, or opening the URL before running npm run dev), the Studio was silently swallowing the fetch error and rendering an infinite pulsing dot with no recovery path. Users had no idea what happened. Instead of showing an error and asking the user to act, the Studio now polls /api/projects every 2 seconds and automatically transitions into the full editor the moment the server becomes available — no manual reload required. Also fixes how agents are instructed about the dev server: CLAUDE.md and AGENTS.md listed `npm run dev` as a one-liner comment identical to other commands, giving no indication it blocks until stopped. Agents (including Claude Code) were running it in foreground, timing out after ~2 minutes, and silently killing the server. Added an explicit note that it must be started as a background process. * fix(studio): auto-reconnect when preview server is not running Two issues combined to produce the "reloading the tab kills the whole" experience for users running with an AI agent: 1. Agents silently killed the server — CLAUDE.md/AGENTS.md listed npm run dev with no indication it blocks. Agents ran it in foreground, the Bash tool timed out, and the process died. Added an explicit run_in_background instruction. 2. Studio had no recovery path — fetch errors were swallowed, leaving a permanent pulsing dot with no way out. Now the Studio polls every 2s and auto-transitions the moment the server responds. Also fixes the bookmark-reload case: the hash path previously bailed out before pinging the server, so a dead server + saved URL produced a blank editor instead of the waiting state. The server is now always contacted first, regardless of whether a hash project ID is present. Timer cleanup (cancelled flag + clearTimeout) prevents setState on unmounted components under StrictMode dev re-mounts. Extracted into useServerConnection hook to keep App.tsx under the 500 LOC limit. --- packages/cli/src/templates/_shared/AGENTS.md | 6 +- packages/cli/src/templates/_shared/CLAUDE.md | 6 +- packages/studio/src/App.tsx | 34 ++------- .../studio/src/components/StudioSplash.tsx | 17 +++++ .../studio/src/hooks/useServerConnection.ts | 71 +++++++++++++++++++ 5 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 packages/studio/src/components/StudioSplash.tsx create mode 100644 packages/studio/src/hooks/useServerConnection.ts diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 4f5e90cea..6876b6358 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -13,13 +13,17 @@ Skills encode patterns like `window.__timelines` registration, `data-*` attribut ## Commands ```bash -npm run dev # preview in browser (studio editor) +npm run dev # start the preview server (long-running — keep it alive in background) npm run check # lint + validate + inspect npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes docs # reference docs in terminal ``` +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> Always run it as a background process so it stays alive while you edit compositions. +> Running it in the foreground will time out and kill the server, breaking the browser preview. + ## Project Structure - `index.html` — main composition (root timeline) diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index fea7ce58f..2359316c6 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -25,7 +25,7 @@ ## Commands ```bash -npm run dev # preview in browser (studio editor) +npm run dev # start the preview server (long-running — keep it alive in background) npm run check # lint + validate + inspect npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -34,6 +34,10 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground +> command — it will time out and the server will die, breaking the browser preview. + ## Documentation **For quick reference**, use the local CLI docs command (no network required): diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4affd71c1..fcfe3caee 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,5 +1,4 @@ import { useState, useCallback, useRef, useMemo } from "react"; -import { useMountEffect } from "./hooks/useMountEffect"; import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; @@ -21,7 +20,6 @@ import { useFrameCapture } from "./hooks/useFrameCapture"; import { useLintModal } from "./hooks/useLintModal"; import { useCompositionDimensions } from "./hooks/useCompositionDimensions"; import { useToast } from "./hooks/useToast"; -import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, @@ -38,29 +36,11 @@ import { StudioProvider, type StudioContextValue } from "./contexts/StudioContex import { PanelLayoutProvider } from "./contexts/PanelLayoutContext"; import { FileManagerProvider } from "./contexts/FileManagerContext"; import { DomEditProvider } from "./contexts/DomEditContext"; +import { StudioSplash } from "./components/StudioSplash"; +import { useServerConnection } from "./hooks/useServerConnection"; export function StudioApp() { - const [projectId, setProjectId] = useState(null); - const [resolving, setResolving] = useState(true); - useMountEffect(() => { - const hashProjectId = parseProjectIdFromHash(window.location.hash); - if (hashProjectId) { - setProjectId(hashProjectId); - setResolving(false); - return; - } - fetch("/api/projects") - .then((r) => r.json()) - .then((data) => { - const first = (data.projects ?? [])[0]; - if (first) { - setProjectId(first.id); - window.location.hash = buildProjectHash(first.id); - } - }) - .catch(() => {}) - .finally(() => setResolving(false)); - }); + const { projectId, resolving, waitingForServer } = useServerConnection(); const [activeCompPath, setActiveCompPath] = useState(null); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); @@ -341,12 +321,8 @@ export function StudioApp() { toggleTimelineVisibility, }; - if (resolving || !projectId) { - return ( -
-
-
- ); + if (resolving || waitingForServer || !projectId) { + return ; } const timelineToolbar = ; diff --git a/packages/studio/src/components/StudioSplash.tsx b/packages/studio/src/components/StudioSplash.tsx new file mode 100644 index 000000000..366542c29 --- /dev/null +++ b/packages/studio/src/components/StudioSplash.tsx @@ -0,0 +1,17 @@ +export function StudioSplash({ waiting }: { waiting?: boolean }) { + return ( +
+ {waiting ? ( +
+
+

+ Waiting for preview server… run{" "} + npm run dev +

+
+ ) : ( +
+ )} +
+ ); +} diff --git a/packages/studio/src/hooks/useServerConnection.ts b/packages/studio/src/hooks/useServerConnection.ts new file mode 100644 index 000000000..396b7a780 --- /dev/null +++ b/packages/studio/src/hooks/useServerConnection.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting"; +import { useMountEffect } from "./useMountEffect"; + +interface ServerConnectionState { + projectId: string | null; + resolving: boolean; + waitingForServer: boolean; +} + +/** + * Resolves the active project ID by pinging /api/projects. + * + * If the hash contains a project ID the server is still contacted — this + * ensures a dead server (bookmark-reload case) enters the waiting state + * rather than mounting the full Studio against a non-responsive API. + * + * Polls every 2 s until the server responds, then transitions automatically. + * Cleans up pending timers on unmount so it is safe under React StrictMode. + */ +export function useServerConnection(): ServerConnectionState { + const [projectId, setProjectId] = useState(null); + const [resolving, setResolving] = useState(true); + const [waitingForServer, setWaitingForServer] = useState(false); + + useMountEffect(() => { + const hashProjectId = parseProjectIdFromHash(window.location.hash); + let cancelled = false; + let retryTimer: ReturnType | null = null; + + function scheduleRetry() { + setWaitingForServer(true); + retryTimer = window.setTimeout(tryConnect, 2000); + } + + function tryConnect() { + fetch("/api/projects") + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + if (hashProjectId) { + setProjectId(hashProjectId); + setWaitingForServer(false); + } else { + const first = (data.projects ?? [])[0]; + if (first) { + setProjectId(first.id); + setWaitingForServer(false); + window.location.hash = buildProjectHash(first.id); + } else { + scheduleRetry(); + } + } + }) + .catch(() => { + if (!cancelled) scheduleRetry(); + }) + .finally(() => { + if (!cancelled) setResolving(false); + }); + } + + tryConnect(); + return () => { + cancelled = true; + if (retryTimer !== null) clearTimeout(retryTimer); + }; + }); + + return { projectId, resolving, waitingForServer }; +}