Skip to content

Commit 403daca

Browse files
committed
fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation
1 parent 7bff49e commit 403daca

17 files changed

Lines changed: 136 additions & 85 deletions

.github/workflows/ci.yml

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ jobs:
267267
- run: bun run --cwd packages/core build:hyperframes-runtime
268268
- name: Start studio and check for runtime errors
269269
run: |
270-
# Start the studio dev server in the background
270+
# Start the studio Vite dev server (fast — no bundle step)
271271
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
272272
SERVER_PID=$!
273273
@@ -283,8 +283,8 @@ jobs:
283283
exit 1
284284
fi
285285
286-
# Load the studio in headless Chrome and capture console errors
287-
# puppeteer is a dependency of @hyperframes/producer; resolve from there
286+
# Load the studio in headless Chrome with API mocking to trigger
287+
# the full splash→main transition (catches hooks-after-early-return bugs)
288288
cd packages/producer
289289
node --input-type=module <<'SMOKE_EOF'
290290
import puppeteer from "puppeteer";
@@ -298,18 +298,55 @@ jobs:
298298
page.on("console", (msg) => {
299299
if (msg.type() === "error") errors.push(msg.text());
300300
});
301-
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
301+
302+
// Mock the project API so the studio transitions past the splash screen.
303+
// Without this, useServerConnection stays in "waiting" and the full React
304+
// tree (with all hooks) never renders — missing hooks-order violations.
305+
await page.setRequestInterception(true);
306+
page.on("request", (req) => {
307+
const url = req.url();
308+
if (url.includes("/api/projects")) {
309+
req.respond({
310+
status: 200,
311+
contentType: "application/json",
312+
body: JSON.stringify({ projects: [{ id: "smoke-test" }] }),
313+
});
314+
} else if (url.includes("/api/") && !url.includes("/__vite")) {
315+
req.respond({ status: 200, contentType: "application/json", body: "{}" });
316+
} else {
317+
req.continue();
318+
}
319+
});
320+
321+
await page.goto("http://localhost:5199/#project=smoke-test", {
322+
waitUntil: "networkidle0",
323+
timeout: 30000,
324+
});
325+
// Wait for React to render past splash into the full studio UI
302326
await new Promise((r) => setTimeout(r, 3000));
327+
328+
// Check for React error boundary (catches hooks violations, render crashes)
329+
const errorBoundary = await page.evaluate(() => {
330+
const text = document.body.innerText;
331+
if (text.includes("Something went wrong")) return text;
332+
return null;
333+
});
334+
if (errorBoundary) {
335+
errors.push("React error boundary triggered: " + errorBoundary);
336+
}
303337
await browser.close();
304338
const fatal = errors.filter(
305-
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
339+
(e) =>
340+
!e.includes("favicon") &&
341+
!e.includes("ERR_CONNECTION_REFUSED") &&
342+
!e.includes("Failed to fetch"),
306343
);
307344
if (fatal.length > 0) {
308-
console.error("FAIL: studio had runtime errors on load:");
345+
console.error("FAIL: studio had runtime errors:");
309346
for (const e of fatal) console.error(" •", e);
310347
process.exit(1);
311348
}
312-
console.log("PASS: studio loaded without runtime errors");
349+
console.log("PASS: studio loaded and transitioned without runtime errors");
313350
SMOKE_EOF
314351
315352
kill $SERVER_PID 2>/dev/null || true
-20.4 KB
Binary file not shown.

packages/studio/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,6 @@ export function StudioApp() {
452452
timelineVisible,
453453
toggleTimelineVisibility,
454454
});
455-
if (resolving || waitingForServer || !projectId)
456-
return <StudioSplash waiting={waitingForServer} />;
457455
const timelineToolbar = useMemo(
458456
() => (
459457
<TimelineToolbar
@@ -464,6 +462,8 @@ export function StudioApp() {
464462
),
465463
[toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit],
466464
);
465+
if (resolving || waitingForServer || !projectId)
466+
return <StudioSplash waiting={waitingForServer} />;
467467
return (
468468
<StudioShellProvider value={studioCtxValue}>
469469
<StudioPlaybackProvider value={studioCtxValue}>

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type DomEditSelection } from "./domEditing";
44
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
55
import {
66
type BlockedMoveState,
7+
type DomEditGroupPathOffsetCommit,
78
type FocusableDomEditOverlay,
89
type GestureState,
910
type GroupGestureState,
@@ -27,11 +28,7 @@ export {
2728
resolveDomEditResizeGesture,
2829
resolveDomEditRotationGesture,
2930
} from "./domEditOverlayGestures";
30-
31-
export interface DomEditGroupPathOffsetCommit {
32-
selection: DomEditSelection;
33-
next: { x: number; y: number };
34-
}
31+
export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
3532

3633
interface DomEditOverlayProps {
3734
iframeRef: RefObject<HTMLIFrameElement | null>;

packages/studio/src/components/editor/domEditOverlayGestures.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import type { RefObject } from "react";
12
import type { DomEditSelection } from "./domEditing";
23
import type {
34
StudioBoxSizeSnapshot,
45
StudioPathOffsetSnapshot,
56
StudioRotationSnapshot,
67
} from "./manualEdits";
78
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
8-
import type { GroupOverlayItem } from "./domEditOverlayGeometry";
9+
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
910
import type { SnapContext } from "./snapTargetCollection";
11+
import type { SnapGuidesState } from "./SnapGuideOverlay";
1012

1113
export type GestureKind = "drag" | "resize" | "rotate";
1214

@@ -143,3 +145,54 @@ export function resolveDomEditRotationGesture(input: {
143145
export function hasDomEditRotationChanged(initialAngle: number, nextAngle: number): boolean {
144146
return Math.abs(nextAngle - initialAngle) >= ROTATION_COMMIT_EPSILON_DEGREES;
145147
}
148+
149+
// ── Shared types for DomEditOverlay gesture wiring ──
150+
// These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
151+
// to break circular imports between those files.
152+
153+
export interface DomEditGroupPathOffsetCommit {
154+
selection: DomEditSelection;
155+
next: { x: number; y: number };
156+
}
157+
158+
// Refs are stable across renders; values are read via .current.
159+
export type UseDomEditOverlayGesturesOptions = {
160+
overlayRef: RefObject<HTMLDivElement | null>;
161+
iframeRef: RefObject<HTMLIFrameElement | null>;
162+
boxRef: RefObject<HTMLDivElement | null>;
163+
selectionRef: RefObject<DomEditSelection | null>;
164+
overlayRectRef: RefObject<OverlayRect | null>;
165+
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
166+
gestureRef: RefObject<GestureState | null>;
167+
groupGestureRef: RefObject<GroupGestureState | null>;
168+
blockedMoveRef: RefObject<BlockedMoveState | null>;
169+
rafPausedRef: RefObject<boolean>;
170+
suppressNextBoxClickRef: RefObject<boolean>;
171+
setOverlayRect: (next: OverlayRect | null) => void;
172+
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
173+
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
174+
onManualDragStartRef: RefObject<(() => void) | undefined>;
175+
onPathOffsetCommitRef: RefObject<
176+
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
177+
>;
178+
onGroupPathOffsetCommitRef: RefObject<
179+
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
180+
>;
181+
onBoxSizeCommitRef: RefObject<
182+
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
183+
>;
184+
onRotationCommitRef: RefObject<
185+
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
186+
>;
187+
onCanvasPointerMoveRef: RefObject<
188+
(
189+
e: React.PointerEvent<HTMLDivElement>,
190+
o?: { preferClipAncestor?: boolean },
191+
) => Promise<DomEditSelection | null>
192+
>;
193+
onCanvasMouseDown: (
194+
e: React.MouseEvent<HTMLDivElement>,
195+
o?: { preferClipAncestor?: boolean },
196+
) => void;
197+
snapGuidesRef: RefObject<SnapGuidesState | null>;
198+
};

packages/studio/src/components/editor/domEditOverlayStartGesture.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ import {
2121
filterNestedDomEditGroupItems,
2222
selectionCacheKey,
2323
} from "./domEditOverlayGeometry";
24-
import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
25-
import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
24+
import {
25+
type GestureKind,
26+
type GestureState,
27+
type UseDomEditOverlayGesturesOptions,
28+
} from "./domEditOverlayGestures";
2629
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
2730

2831
export function startGroupDrag(

packages/studio/src/components/editor/useDomEditOverlayGestures.ts

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,14 @@ import {
3333
} from "./domEditOverlayGeometry";
3434
import {
3535
BLOCKED_MOVE_THRESHOLD_PX,
36-
type BlockedMoveState,
3736
type GestureKind,
3837
type GestureState,
3938
type GroupGestureState,
39+
type UseDomEditOverlayGesturesOptions,
4040
hasDomEditRotationChanged,
4141
resolveDomEditResizeGesture,
4242
resolveDomEditRotationGesture,
4343
} from "./domEditOverlayGestures";
44-
import type { DomEditGroupPathOffsetCommit } from "./DomEditOverlay";
4544
import {
4645
startGesture as _startGesture,
4746
startGroupDrag as _startGroupDrag,
@@ -52,50 +51,6 @@ import {
5251
resolveEquidistanceGuides,
5352
SNAP_THRESHOLD_PX,
5453
} from "./snapEngine";
55-
import type { SnapGuidesState } from "./SnapGuideOverlay";
56-
57-
// Refs are stable across renders; values are read via .current.
58-
export type UseDomEditOverlayGesturesOptions = {
59-
overlayRef: RefObject<HTMLDivElement | null>;
60-
iframeRef: RefObject<HTMLIFrameElement | null>;
61-
boxRef: RefObject<HTMLDivElement | null>;
62-
selectionRef: RefObject<DomEditSelection | null>;
63-
overlayRectRef: RefObject<OverlayRect | null>;
64-
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
65-
gestureRef: RefObject<GestureState | null>;
66-
groupGestureRef: RefObject<GroupGestureState | null>;
67-
blockedMoveRef: RefObject<BlockedMoveState | null>;
68-
rafPausedRef: RefObject<boolean>;
69-
suppressNextBoxClickRef: RefObject<boolean>;
70-
setOverlayRect: (next: OverlayRect | null) => void;
71-
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
72-
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
73-
onManualDragStartRef: RefObject<(() => void) | undefined>;
74-
onPathOffsetCommitRef: RefObject<
75-
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
76-
>;
77-
onGroupPathOffsetCommitRef: RefObject<
78-
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
79-
>;
80-
onBoxSizeCommitRef: RefObject<
81-
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
82-
>;
83-
onRotationCommitRef: RefObject<
84-
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
85-
>;
86-
onCanvasPointerMoveRef: RefObject<
87-
(
88-
e: React.PointerEvent<HTMLDivElement>,
89-
o?: { preferClipAncestor?: boolean },
90-
) => Promise<DomEditSelection | null>
91-
>;
92-
onCanvasMouseDown: (
93-
e: React.MouseEvent<HTMLDivElement>,
94-
o?: { preferClipAncestor?: boolean },
95-
) => void;
96-
snapGuidesRef: RefObject<SnapGuidesState | null>;
97-
};
98-
9954
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
10055
const setDraftOverlayRect = (next: OverlayRect) => {
10156
opts.setOverlayRect(next);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DomEditSelection } from "../components/editor/domEditing";
2+
import type { PatchOperation } from "../utils/sourcePatcher";
3+
4+
export type PersistDomEditOperations = (
5+
selection: DomEditSelection,
6+
operations: PatchOperation[],
7+
options?: {
8+
label?: string;
9+
coalesceKey?: string;
10+
skipRefresh?: boolean;
11+
prepareContent?: (html: string, sourceFile: string) => string;
12+
shouldSave?: () => boolean;
13+
},
14+
) => Promise<void>;

packages/studio/src/hooks/timelineEditingHelpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type { TimelineElement } from "../player";
1+
import type { TimelineElement } from "../player/store/playerStore";
22
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
33
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
44
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
55
import type { EditHistoryKind } from "../utils/editHistory";
66

77
// ── Types ──
88

9-
interface RecordEditInput {
9+
export interface RecordEditInput {
1010
label: string;
1111
kind: EditHistoryKind;
1212
coalesceKey?: string;

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { useCallback, useRef } from "react";
22
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
33
import { FONT_EXT } from "../utils/mediaTypes";
4-
import type { PatchOperation } from "../utils/sourcePatcher";
4+
55
import { trackStudioEvent } from "../utils/studioTelemetry";
66
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
77
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
88
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
99
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
1010
import type { EditHistoryKind } from "../utils/editHistory";
11+
import type { PersistDomEditOperations } from "./domEditCommitTypes";
1112
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
1213
import { useDomEditTextCommits } from "./useDomEditTextCommits";
1314
import { useDomGeometryCommits } from "./useDomGeometryCommits";
@@ -48,17 +49,7 @@ interface RecordEditInput {
4849
files: Record<string, { before: string; after: string }>;
4950
}
5051

51-
export type PersistDomEditOperations = (
52-
selection: DomEditSelection,
53-
operations: PatchOperation[],
54-
options?: {
55-
label?: string;
56-
coalesceKey?: string;
57-
skipRefresh?: boolean;
58-
prepareContent?: (html: string, sourceFile: string) => string;
59-
shouldSave?: () => boolean;
60-
},
61-
) => Promise<void>;
52+
export type { PersistDomEditOperations } from "./domEditCommitTypes";
6253

6354
export interface UseDomEditCommitsParams {
6455
activeCompPath: string | null;

0 commit comments

Comments
 (0)