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 = '
Test
'; + 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";