mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation (#1422)
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user