Skip to content

Commit 8afedab

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

17 files changed

Lines changed: 123 additions & 89 deletions

.github/workflows/ci.yml

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -265,26 +265,36 @@ jobs:
265265
- uses: ./.github/actions/prepare-ffmpeg-bin
266266
- run: bun install --frozen-lockfile
267267
- run: bun run --cwd packages/core build:hyperframes-runtime
268-
- name: Start studio and check for runtime errors
268+
- run: bun run build
269+
- name: Start studio with project and check for runtime errors
269270
run: |
270-
# Start the studio dev server in the background
271-
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
271+
# Create a minimal project so the studio boots fully (not just splash)
272+
mkdir -p /tmp/smoke-project
273+
cat > /tmp/smoke-project/index.html <<'PROJHTML'
274+
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1" data-start="0">
275+
<div class="clip" id="smoke-el" data-start="0" data-duration="1" style="color:white;font-size:48px;">Smoke</div>
276+
</div>
277+
PROJHTML
278+
279+
# Start the preview server from inside the project dir
280+
cd /tmp/smoke-project
281+
npx tsx $GITHUB_WORKSPACE/packages/cli/src/cli.ts preview --port 5199 --no-open &
272282
SERVER_PID=$!
283+
cd $GITHUB_WORKSPACE
273284
274-
# Wait for the server to be ready (up to 20s)
275-
for i in $(seq 1 40); do
285+
# Wait for the server to be ready (up to 60s)
286+
for i in $(seq 1 120); do
276287
if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi
277288
sleep 0.5
278289
done
279290
280291
if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then
281-
echo "FAIL: studio dev server did not start"
292+
echo "FAIL: studio preview server did not start"
282293
kill $SERVER_PID 2>/dev/null || true
283294
exit 1
284295
fi
285296
286-
# Load the studio in headless Chrome and capture console errors
287-
# puppeteer is a dependency of @hyperframes/producer; resolve from there
297+
# Load the studio in headless Chrome — tests the full boot path
288298
cd packages/producer
289299
node --input-type=module <<'SMOKE_EOF'
290300
import puppeteer from "puppeteer";
@@ -299,17 +309,27 @@ jobs:
299309
if (msg.type() === "error") errors.push(msg.text());
300310
});
301311
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
302-
await new Promise((r) => setTimeout(r, 3000));
312+
// Wait for the studio to fully transition past splash
313+
await new Promise((r) => setTimeout(r, 5000));
314+
// Check for React error boundary (catches hooks violations, render crashes)
315+
const errorBoundary = await page.evaluate(() => {
316+
const text = document.body.innerText;
317+
if (text.includes("Something went wrong")) return text;
318+
return null;
319+
});
320+
if (errorBoundary) {
321+
errors.push("React error boundary triggered: " + errorBoundary);
322+
}
303323
await browser.close();
304324
const fatal = errors.filter(
305325
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
306326
);
307327
if (fatal.length > 0) {
308-
console.error("FAIL: studio had runtime errors on load:");
328+
console.error("FAIL: studio had runtime errors:");
309329
for (const e of fatal) console.error(" •", e);
310330
process.exit(1);
311331
}
312-
console.log("PASS: studio loaded without runtime errors");
332+
console.log("PASS: studio loaded and transitioned without runtime errors");
313333
SMOKE_EOF
314334
315335
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)