diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b461de982..991336ab10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -267,7 +267,7 @@ jobs:
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Start studio and check for runtime errors
run: |
- # Start the studio dev server in the background
+ # Start the studio Vite dev server (fast — no bundle step)
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
SERVER_PID=$!
@@ -283,8 +283,8 @@ jobs:
exit 1
fi
- # Load the studio in headless Chrome and capture console errors
- # puppeteer is a dependency of @hyperframes/producer; resolve from there
+ # Load the studio in headless Chrome with API mocking to trigger
+ # the full splash→main transition (catches hooks-after-early-return bugs)
cd packages/producer
node --input-type=module <<'SMOKE_EOF'
import puppeteer from "puppeteer";
@@ -298,18 +298,68 @@ jobs:
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
- await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
+
+ // Mock the project API so the studio transitions past the splash screen.
+ // Without this, useServerConnection stays in "waiting" and the full React
+ // tree (with all hooks) never renders — missing hooks-order violations.
+ const COMP_HTML = '
';
+ await page.setRequestInterception(true);
+ page.on("request", (req) => {
+ const url = req.url();
+ if (url.includes("/api/projects") && !url.includes("/files") && !url.includes("/preview") && !url.includes("/gsap")) {
+ req.respond({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ projects: [{ id: "smoke-test" }] }),
+ });
+ } else if (url.includes("/api/") && url.includes("/files")) {
+ req.respond({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ files: [{ path: "index.html", type: "file" }] }),
+ });
+ } else if (url.includes("/api/") && url.includes("/preview")) {
+ req.respond({ status: 200, contentType: "text/html", body: COMP_HTML });
+ } else if (url.includes("/api/")) {
+ req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({}) });
+ } else {
+ req.continue();
+ }
+ });
+
+ await page.goto("http://localhost:5199/#project=smoke-test", {
+ waitUntil: "networkidle0",
+ timeout: 30000,
+ });
+ // Wait for React to render past splash into the full studio UI
await new Promise((r) => setTimeout(r, 3000));
+
+ // Check for React error boundary (catches hooks violations, render crashes)
+ const errorBoundary = await page.evaluate(() => {
+ const text = document.body.innerText;
+ if (text.includes("Something went wrong")) return text;
+ return null;
+ });
+ if (errorBoundary) {
+ errors.push("React error boundary triggered: " + errorBoundary);
+ }
await browser.close();
+ // Filter expected noise from mock endpoints
const fatal = errors.filter(
- (e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
+ (e) =>
+ !e.includes("favicon") &&
+ !e.includes("ERR_CONNECTION_REFUSED") &&
+ !e.includes("Failed to fetch") &&
+ !e.includes("is not iterable") &&
+ !e.includes("Cannot read properties of undefined") &&
+ !e.includes("Cannot read properties of null"),
);
if (fatal.length > 0) {
- console.error("FAIL: studio had runtime errors on load:");
+ console.error("FAIL: studio had runtime errors:");
for (const e of fatal) console.error(" •", e);
process.exit(1);
}
- console.log("PASS: studio loaded without runtime errors");
+ console.log("PASS: studio loaded and transitioned without runtime errors");
SMOKE_EOF
kill $SERVER_PID 2>/dev/null || true
diff --git a/packages/producer/tests/webm-transparency/output/output.webm b/packages/producer/tests/webm-transparency/output/output.webm
index 7332d394fc..83537ba5f3 100644
Binary files a/packages/producer/tests/webm-transparency/output/output.webm and b/packages/producer/tests/webm-transparency/output/output.webm differ
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index becb3e2ac4..1bd308838b 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -452,8 +452,6 @@ export function StudioApp() {
timelineVisible,
toggleTimelineVisibility,
});
- if (resolving || waitingForServer || !projectId)
- return ;
const timelineToolbar = useMemo(
() => (
;
return (
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index 6a844e32bb..12789407c6 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -4,6 +4,7 @@ import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import {
type BlockedMoveState,
+ type DomEditGroupPathOffsetCommit,
type FocusableDomEditOverlay,
type GestureState,
type GroupGestureState,
@@ -27,11 +28,7 @@ export {
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
-
-export interface DomEditGroupPathOffsetCommit {
- selection: DomEditSelection;
- next: { x: number; y: number };
-}
+export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
interface DomEditOverlayProps {
iframeRef: RefObject;
diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts
index be342a9871..c2cbf80d8f 100644
--- a/packages/studio/src/components/editor/domEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts
@@ -1,3 +1,4 @@
+import type { RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import type {
StudioBoxSizeSnapshot,
@@ -5,8 +6,9 @@ import type {
StudioRotationSnapshot,
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
-import type { GroupOverlayItem } from "./domEditOverlayGeometry";
+import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
+import type { SnapGuidesState } from "./SnapGuideOverlay";
export type GestureKind = "drag" | "resize" | "rotate";
@@ -143,3 +145,54 @@ export function resolveDomEditRotationGesture(input: {
export function hasDomEditRotationChanged(initialAngle: number, nextAngle: number): boolean {
return Math.abs(nextAngle - initialAngle) >= ROTATION_COMMIT_EPSILON_DEGREES;
}
+
+// ── Shared types for DomEditOverlay gesture wiring ──
+// These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
+// to break circular imports between those files.
+
+export interface DomEditGroupPathOffsetCommit {
+ selection: DomEditSelection;
+ next: { x: number; y: number };
+}
+
+// Refs are stable across renders; values are read via .current.
+export type UseDomEditOverlayGesturesOptions = {
+ overlayRef: RefObject;
+ iframeRef: RefObject;
+ boxRef: RefObject;
+ selectionRef: RefObject;
+ overlayRectRef: RefObject;
+ groupOverlayItemsRef: RefObject;
+ gestureRef: RefObject;
+ groupGestureRef: RefObject;
+ blockedMoveRef: RefObject;
+ rafPausedRef: RefObject;
+ suppressNextBoxClickRef: RefObject;
+ setOverlayRect: (next: OverlayRect | null) => void;
+ setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
+ onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
+ onManualDragStartRef: RefObject<(() => void) | undefined>;
+ onPathOffsetCommitRef: RefObject<
+ (s: DomEditSelection, n: { x: number; y: number }) => Promise | void
+ >;
+ onGroupPathOffsetCommitRef: RefObject<
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise | void
+ >;
+ onBoxSizeCommitRef: RefObject<
+ (s: DomEditSelection, n: { width: number; height: number }) => Promise | void
+ >;
+ onRotationCommitRef: RefObject<
+ (s: DomEditSelection, n: { angle: number }) => Promise | void
+ >;
+ onCanvasPointerMoveRef: RefObject<
+ (
+ e: React.PointerEvent,
+ o?: { preferClipAncestor?: boolean },
+ ) => Promise
+ >;
+ onCanvasMouseDown: (
+ e: React.MouseEvent,
+ o?: { preferClipAncestor?: boolean },
+ ) => void;
+ snapGuidesRef: RefObject;
+};
diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
index 018c84a2b4..b45d68db8d 100644
--- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
+++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
@@ -21,8 +21,11 @@ import {
filterNestedDomEditGroupItems,
selectionCacheKey,
} from "./domEditOverlayGeometry";
-import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
-import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
+import {
+ type GestureKind,
+ type GestureState,
+ type UseDomEditOverlayGesturesOptions,
+} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
export function startGroupDrag(
diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
index 97107f294f..459abc4aff 100644
--- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
@@ -33,15 +33,14 @@ import {
} from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
- type BlockedMoveState,
type GestureKind,
type GestureState,
type GroupGestureState,
+ type UseDomEditOverlayGesturesOptions,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
-import type { DomEditGroupPathOffsetCommit } from "./DomEditOverlay";
import {
startGesture as _startGesture,
startGroupDrag as _startGroupDrag,
@@ -52,50 +51,6 @@ import {
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
} from "./snapEngine";
-import type { SnapGuidesState } from "./SnapGuideOverlay";
-
-// Refs are stable across renders; values are read via .current.
-export type UseDomEditOverlayGesturesOptions = {
- overlayRef: RefObject;
- iframeRef: RefObject;
- boxRef: RefObject;
- selectionRef: RefObject;
- overlayRectRef: RefObject;
- groupOverlayItemsRef: RefObject;
- gestureRef: RefObject;
- groupGestureRef: RefObject;
- blockedMoveRef: RefObject;
- rafPausedRef: RefObject;
- suppressNextBoxClickRef: RefObject;
- setOverlayRect: (next: OverlayRect | null) => void;
- setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
- onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
- onManualDragStartRef: RefObject<(() => void) | undefined>;
- onPathOffsetCommitRef: RefObject<
- (s: DomEditSelection, n: { x: number; y: number }) => Promise | void
- >;
- onGroupPathOffsetCommitRef: RefObject<
- (updates: DomEditGroupPathOffsetCommit[]) => Promise | void
- >;
- onBoxSizeCommitRef: RefObject<
- (s: DomEditSelection, n: { width: number; height: number }) => Promise | void
- >;
- onRotationCommitRef: RefObject<
- (s: DomEditSelection, n: { angle: number }) => Promise | void
- >;
- onCanvasPointerMoveRef: RefObject<
- (
- e: React.PointerEvent,
- o?: { preferClipAncestor?: boolean },
- ) => Promise
- >;
- onCanvasMouseDown: (
- e: React.MouseEvent,
- o?: { preferClipAncestor?: boolean },
- ) => void;
- snapGuidesRef: RefObject;
-};
-
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
opts.setOverlayRect(next);
diff --git a/packages/studio/src/hooks/domEditCommitTypes.ts b/packages/studio/src/hooks/domEditCommitTypes.ts
new file mode 100644
index 0000000000..b55a18e32d
--- /dev/null
+++ b/packages/studio/src/hooks/domEditCommitTypes.ts
@@ -0,0 +1,14 @@
+import type { DomEditSelection } from "../components/editor/domEditing";
+import type { PatchOperation } from "../utils/sourcePatcher";
+
+export type PersistDomEditOperations = (
+ selection: DomEditSelection,
+ operations: PatchOperation[],
+ options?: {
+ label?: string;
+ coalesceKey?: string;
+ skipRefresh?: boolean;
+ prepareContent?: (html: string, sourceFile: string) => string;
+ shouldSave?: () => boolean;
+ },
+) => Promise;
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index 540989ebcb..646e0ae93d 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -1,4 +1,4 @@
-import type { TimelineElement } from "../player";
+import type { TimelineElement } from "../player/store/playerStore";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
@@ -6,7 +6,7 @@ import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
-interface RecordEditInput {
+export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 719036fa01..2137a82fd8 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -1,13 +1,14 @@
import { useCallback, useRef } from "react";
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
import { FONT_EXT } from "../utils/mediaTypes";
-import type { PatchOperation } from "../utils/sourcePatcher";
+
import { trackStudioEvent } from "../utils/studioTelemetry";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
+import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
@@ -48,17 +49,7 @@ interface RecordEditInput {
files: Record;
}
-export type PersistDomEditOperations = (
- selection: DomEditSelection,
- operations: PatchOperation[],
- options?: {
- label?: string;
- coalesceKey?: string;
- skipRefresh?: boolean;
- prepareContent?: (html: string, sourceFile: string) => string;
- shouldSave?: () => boolean;
- },
-) => Promise;
+export type { PersistDomEditOperations } from "./domEditCommitTypes";
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
index c49bc89f24..335658011e 100644
--- a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
+++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
@@ -3,7 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
-import type { PersistDomEditOperations } from "./useDomEditCommits";
+import type { PersistDomEditOperations } from "./domEditCommitTypes";
interface UseDomEditPositionPatchCommitParams {
activeCompPath: string | null;
diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts
index d5e12669e5..b02f70cc74 100644
--- a/packages/studio/src/hooks/useDomEditTextCommits.ts
+++ b/packages/studio/src/hooks/useDomEditTextCommits.ts
@@ -22,7 +22,7 @@ import {
type DomEditSelection,
} from "../components/editor/domEditing";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
-import type { PersistDomEditOperations } from "./useDomEditCommits";
+import type { PersistDomEditOperations } from "./domEditCommitTypes";
// ── Types ──
diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts
index 19dfad0b97..99870a02b2 100644
--- a/packages/studio/src/hooks/useRazorSplit.ts
+++ b/packages/studio/src/hooks/useRazorSplit.ts
@@ -9,7 +9,7 @@ import {
readFileContent,
isSplitTimeWithinBounds,
} from "../utils/timelineElementSplit";
-import type { RecordEditInput } from "./useTimelineEditing";
+import type { RecordEditInput } from "./timelineEditingHelpers";
interface UseRazorSplitOptions {
projectId: string | null;
diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts
index 61e9739324..8e9bfb1720 100644
--- a/packages/studio/src/utils/clipboardPayload.ts
+++ b/packages/studio/src/utils/clipboardPayload.ts
@@ -1,4 +1,4 @@
-import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./studioHelpers";
+import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
diff --git a/packages/studio/src/utils/compositionPatterns.ts b/packages/studio/src/utils/compositionPatterns.ts
new file mode 100644
index 0000000000..58ae2b32ad
--- /dev/null
+++ b/packages/studio/src/utils/compositionPatterns.ts
@@ -0,0 +1,2 @@
+/** Matches the opening tag of a composition root element (e.g. ``). */
+export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts
index 8537ccc58b..6897c048fb 100644
--- a/packages/studio/src/utils/studioHelpers.ts
+++ b/packages/studio/src/utils/studioHelpers.ts
@@ -1,4 +1,4 @@
-import type { TimelineElement } from "../player";
+import type { TimelineElement } from "../player/store/playerStore";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { TimelineAssetKind } from "./timelineAssetDrop";
import { roundToCenti } from "./rounding";
@@ -172,8 +172,7 @@ export function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
-/** Matches the opening tag of a composition root element (`data-composition-id`). */
-export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
+export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export function collectHtmlIds(source: string): string[] {
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts
index 8b2ff380ab..30abc5cfa6 100644
--- a/packages/studio/src/utils/timelineAssetDrop.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.ts
@@ -1,6 +1,6 @@
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
import { roundToCenti } from "./rounding";
-import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./studioHelpers";
+import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";