mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.
Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.
Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types
Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test
GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile
Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame
Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
toolbar actions, navigation, and render starts
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { FONT_EXT } from "../utils/mediaTypes";
|
||||
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
||||
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
||||
@@ -45,7 +46,7 @@ interface RecordEditInput {
|
||||
|
||||
export type PersistDomEditOperations = (
|
||||
selection: DomEditSelection,
|
||||
operations: Parameters<typeof applyPatchByTarget>[2][],
|
||||
operations: PatchOperation[],
|
||||
options?: {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
@@ -134,39 +135,61 @@ export function useDomEditCommits({
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const originalContent = data.content;
|
||||
const readResponse = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
|
||||
const readData = (await readResponse.json()) as { content?: string };
|
||||
const originalContent = readData.content;
|
||||
if (typeof originalContent !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
|
||||
let patchedContent = originalContent;
|
||||
for (const operation of operations) {
|
||||
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
||||
}
|
||||
if (options?.prepareContent) {
|
||||
patchedContent = options.prepareContent(patchedContent, targetPath);
|
||||
}
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
|
||||
id: selection.id,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
};
|
||||
|
||||
const patchResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, operations }),
|
||||
},
|
||||
);
|
||||
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
|
||||
|
||||
const patchData = (await patchResponse.json()) as {
|
||||
ok?: boolean;
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
if (!patchData.changed) {
|
||||
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
||||
}
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
const patchedContent =
|
||||
typeof patchData.content === "string" ? patchData.content : originalContent;
|
||||
|
||||
let finalContent = patchedContent;
|
||||
if (options?.prepareContent) {
|
||||
finalContent = options.prepareContent(patchedContent, targetPath);
|
||||
if (finalContent !== patchedContent) {
|
||||
await writeProjectFile(targetPath, finalContent);
|
||||
}
|
||||
}
|
||||
|
||||
await editHistory.recordEdit({
|
||||
label: options?.label ?? "Edit layer",
|
||||
kind: "manual",
|
||||
coalesceKey: options?.coalesceKey,
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
||||
});
|
||||
|
||||
if (options?.skipRefresh) {
|
||||
@@ -177,7 +200,7 @@ export function useDomEditCommits({
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
editHistory.recordEdit,
|
||||
editHistory,
|
||||
writeProjectFile,
|
||||
projectIdRef,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -212,7 +235,7 @@ export function useDomEditCommits({
|
||||
const commitPositionPatchToHtml = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
patches: Parameters<typeof applyPatchByTarget>[2][],
|
||||
patches: PatchOperation[],
|
||||
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
||||
) => {
|
||||
void queueDomEditSave(async () => {
|
||||
@@ -224,6 +247,11 @@ export function useDomEditCommits({
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to save position";
|
||||
showToast(message);
|
||||
trackStudioEvent("save_failure", {
|
||||
source: "dom_edit",
|
||||
label: options.label,
|
||||
error_message: message,
|
||||
});
|
||||
});
|
||||
},
|
||||
[persistDomEditOperations, queueDomEditSave, showToast],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -48,8 +49,8 @@ export function useFileManager({
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRafRef = useRef<number | null>(null);
|
||||
const refreshRafRef = useRef<number | null>(null);
|
||||
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
||||
|
||||
// ── Load file tree when projectId changes ──
|
||||
@@ -145,12 +146,8 @@ export function useFileManager({
|
||||
const path = editingPathRef.current;
|
||||
if (!path) return;
|
||||
|
||||
// Debounce the server write (600ms)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
// Suppress the file-change watcher echo — the save callback triggers
|
||||
// its own refresh, so a second one from the watcher causes a double-reload
|
||||
// race that can leave the player in a non-playable state.
|
||||
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
||||
saveRafRef.current = requestAnimationFrame(() => {
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
@@ -163,11 +160,16 @@ export function useFileManager({
|
||||
recordEdit,
|
||||
})
|
||||
.then(() => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
||||
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
||||
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 600);
|
||||
.catch((error) => {
|
||||
trackStudioEvent("save_failure", {
|
||||
source: "code_editor",
|
||||
error_message: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
|
||||
);
|
||||
@@ -449,7 +451,7 @@ export function useFileManager({
|
||||
// Refs
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
saveRafRef,
|
||||
importedFontAssetsRef,
|
||||
|
||||
// Core I/O
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
export interface InitialPanelLayoutState {
|
||||
rightCollapsed?: boolean | null;
|
||||
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
const toggleLeftSidebar = useCallback(() => {
|
||||
setLeftCollapsed((collapsed) => {
|
||||
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
||||
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
||||
return !collapsed;
|
||||
});
|
||||
}, []);
|
||||
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
panelDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackedSetRightPanelTab = useCallback(
|
||||
(tab: RightPanelTab) => {
|
||||
setRightPanelTab(tab);
|
||||
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
||||
},
|
||||
[setRightPanelTab],
|
||||
);
|
||||
|
||||
return {
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
setRightPanelTab: trackedSetRightPanelTab,
|
||||
toggleLeftSidebar,
|
||||
handlePanelResizeStart,
|
||||
handlePanelResizeMove,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
|
||||
@@ -67,5 +67,15 @@ export function useServerConnection(): ServerConnectionState {
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const next = parseProjectIdFromHash(window.location.hash);
|
||||
if (next && next !== projectId) setProjectId(next);
|
||||
};
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, [projectId]);
|
||||
|
||||
return { projectId, resolving, waitingForServer };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user