fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation (#1422)

This commit is contained in:
Miguel Ángel
2026-06-13 19:23:05 -04:00
committed by GitHub
parent a0d7295367
commit a241f2591e
17 changed files with 149 additions and 85 deletions
+57 -7
View File
@@ -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 = '<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1" data-start="0"><div class="clip" data-start="0" data-duration="1">Test</div></div>';
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
+2 -2
View File
@@ -452,8 +452,6 @@ export function StudioApp() {
timelineVisible,
toggleTimelineVisibility,
});
if (resolving || waitingForServer || !projectId)
return <StudioSplash waiting={waitingForServer} />;
const timelineToolbar = useMemo(
() => (
<TimelineToolbar
@@ -464,6 +462,8 @@ export function StudioApp() {
),
[toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit],
);
if (resolving || waitingForServer || !projectId)
return <StudioSplash waiting={waitingForServer} />;
return (
<StudioShellProvider value={studioCtxValue}>
<StudioPlaybackProvider value={studioCtxValue}>
@@ -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<HTMLIFrameElement | null>;
@@ -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<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
rafPausedRef: RefObject<boolean>;
suppressNextBoxClickRef: RefObject<boolean>;
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> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
>;
onCanvasPointerMoveRef: RefObject<
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};
@@ -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(
@@ -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<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
rafPausedRef: RefObject<boolean>;
suppressNextBoxClickRef: RefObject<boolean>;
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> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
>;
onCanvasPointerMoveRef: RefObject<
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
opts.setOverlayRect(next);
@@ -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<void>;
@@ -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;
+3 -12
View File
@@ -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<string, { before: string; after: string }>;
}
export type PersistDomEditOperations = (
selection: DomEditSelection,
operations: PatchOperation[],
options?: {
label?: string;
coalesceKey?: string;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
},
) => Promise<void>;
export type { PersistDomEditOperations } from "./domEditCommitTypes";
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
@@ -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;
@@ -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 ──
+1 -1
View File
@@ -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;
@@ -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";
@@ -0,0 +1,2 @@
/** Matches the opening tag of a composition root element (e.g. `<div data-composition-id="main">`). */
export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
+2 -3
View File
@@ -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] ?? "");
@@ -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";