mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): make sdk cutover transactional (#2155)
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { PublishSdkSession } from "../utils/sdkCutover";
|
||||
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
||||
|
||||
export interface MutationResult {
|
||||
@@ -22,10 +23,9 @@ export interface CommitMutationOptions {
|
||||
beforeReload?: () => void;
|
||||
/**
|
||||
* Serialize this commit against others sharing the same key. Used to chain
|
||||
* per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
|
||||
* one file can't interleave — which would pair the shadow fidelity diff with a
|
||||
* stale server result and report false ease mismatches. Commits without a key
|
||||
* (and under distinct keys) run concurrently as before.
|
||||
* per-animationId GSAP meta updates. Every commit independently takes the
|
||||
* project/file mutation lock, so this key only adds ordering and can never
|
||||
* bypass whole-file serialization.
|
||||
*/
|
||||
serializeKey?: string;
|
||||
/**
|
||||
@@ -87,6 +87,8 @@ export interface GsapScriptCommitsParams {
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
|
||||
sdkSession?: Composition | null;
|
||||
/** Publish a fully persisted candidate SDK session. */
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
/** Resync the in-memory SDK session after a server-authoritative write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
||||
|
||||
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
try {
|
||||
@@ -87,6 +88,7 @@ interface HistoryResult {
|
||||
interface HistoryFileCallbacks {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
||||
}
|
||||
interface EditHistoryHandle {
|
||||
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
||||
@@ -367,6 +369,11 @@ export function useAppHotkeys({
|
||||
},
|
||||
[domEditSaveTimestampRef, writeProjectFile],
|
||||
);
|
||||
const serializeHistoryFiles = useCallback(
|
||||
<T>(paths: readonly string[], task: () => Promise<T>) =>
|
||||
serializeStudioFileMutations(writeProjectFile, paths, task),
|
||||
[writeProjectFile],
|
||||
);
|
||||
|
||||
const applyHistory = useCallback(
|
||||
async (direction: "undo" | "redo") => {
|
||||
@@ -377,6 +384,7 @@ export function useAppHotkeys({
|
||||
const result = await editHistory[direction]({
|
||||
readFile: readHistoryFile,
|
||||
writeFile: writeHistoryFile,
|
||||
serialize: serializeHistoryFiles,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast(
|
||||
@@ -406,6 +414,7 @@ export function useAppHotkeys({
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryFile,
|
||||
serializeHistoryFiles,
|
||||
onAfterUndoRedo,
|
||||
activeCompPath,
|
||||
forceReloadSdkSession,
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
patchElementBatches,
|
||||
readErrorResponseBody,
|
||||
} from "./useDomEditCommitsHelpers";
|
||||
|
||||
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
@@ -71,16 +71,20 @@ export interface UseDomEditCommitsParams {
|
||||
* path, whose session is already current) so a later SDK edit doesn't
|
||||
* serialize the pre-write doc and revert the server's change. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
||||
/** Stage 7 Step 3c: called before the server-side patch path. */
|
||||
onTrySdkPersist?: (
|
||||
selection: DomEditSelection,
|
||||
operations: PatchOperation[],
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
|
||||
) => Promise<boolean>;
|
||||
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
|
||||
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
||||
) => Promise<CutoverResult>;
|
||||
/** Stage 7 §3.1: called before the server-side delete path. */
|
||||
onTrySdkDelete?: (
|
||||
hfId: string,
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
) => Promise<CutoverResult>;
|
||||
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
|
||||
onReorderShadow?: (targets: string[]) => void;
|
||||
}
|
||||
@@ -175,18 +179,17 @@ export function useDomEditCommits({
|
||||
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
||||
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
||||
// and would drop the injected content. Let the server path run prepareContent.
|
||||
if (
|
||||
onTrySdkPersist &&
|
||||
!options?.prepareContent &&
|
||||
(await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
||||
if (onTrySdkPersist && !options?.prepareContent) {
|
||||
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
||||
label: options?.label,
|
||||
coalesceKey: options?.coalesceKey,
|
||||
skipRefresh: options?.skipRefresh,
|
||||
}))
|
||||
) {
|
||||
// SDK handled it — its in-memory doc is already current, so do NOT
|
||||
// forceReload (that would echo-reload the session we just wrote).
|
||||
return;
|
||||
});
|
||||
if (cutoverCommittedOrThrow(cutover)) {
|
||||
// SDK handled it — its in-memory doc is already current, so do NOT
|
||||
// forceReload (that would echo-reload the session we just wrote).
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the save timestamp before the file write so the SSE file-change
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
|
||||
import { sdkCutoverPersist, sdkDeletePersist, type PublishSdkSession } from "../utils/sdkCutover";
|
||||
import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
|
||||
import { useAskAgentModal } from "./useAskAgentModal";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
@@ -67,6 +67,7 @@ export interface UseDomEditSessionParams {
|
||||
selectSidebarTab?: (tab: SidebarTab) => void;
|
||||
getSidebarTab?: () => SidebarTab;
|
||||
sdkSession?: Composition | null;
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
@@ -108,10 +109,10 @@ export function useDomEditSession({
|
||||
selectSidebarTab,
|
||||
getSidebarTab,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
forceReloadSdkSession,
|
||||
}: UseDomEditSessionParams) {
|
||||
void _setRefreshKey;
|
||||
|
||||
// ── Selection ──
|
||||
|
||||
const {
|
||||
@@ -178,7 +179,6 @@ export function useDomEditSession({
|
||||
previewDocumentVersion,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
});
|
||||
|
||||
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
||||
|
||||
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
||||
@@ -217,6 +217,7 @@ export function useDomEditSession({
|
||||
onFileContentChanged: updateEditingFileContent,
|
||||
showToast,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
writeProjectFile,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
@@ -280,6 +281,8 @@ export function useDomEditSession({
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile,
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -293,6 +296,8 @@ export function useDomEditSession({
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile,
|
||||
publishSession: publishSdkSession,
|
||||
})
|
||||
: undefined,
|
||||
// Resolver shadow for the z-index reorder edit: it takes the server path (no
|
||||
|
||||
@@ -55,24 +55,24 @@ export interface UseDomEditWiringParams {
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => void;
|
||||
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
|
||||
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
|
||||
) => Promise<void>;
|
||||
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
|
||||
addGsapAnimation: (
|
||||
sel: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
time: number,
|
||||
) => Promise<void>;
|
||||
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
updateGsapFromProperty: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
prop: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
) => Promise<void>;
|
||||
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
addKeyframe: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
@@ -105,7 +105,7 @@ export interface UseDomEditWiringParams {
|
||||
animId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ export function useDomEditWiring({
|
||||
removeAllKeyframes,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// ── Preview sync side-effects ──
|
||||
|
||||
@@ -20,10 +20,15 @@ import {
|
||||
type LayerRevealCommitOwnership,
|
||||
} from "../components/editor/useLayerRevealOverride";
|
||||
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
||||
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
||||
|
||||
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
||||
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
|
||||
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
||||
/** Route delete through SDK when session resolves the hf-id. */
|
||||
onTrySdkDelete?: (
|
||||
hfId: string,
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
) => Promise<CutoverResult>;
|
||||
/** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
|
||||
onReorderShadow?: (targets: string[]) => void;
|
||||
/** Resync the SDK session after a server-fallback delete. */
|
||||
@@ -97,7 +102,7 @@ export function useElementLifecycleOps({
|
||||
|
||||
if (onTrySdkDelete && selection.hfId) {
|
||||
const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
|
||||
if (handled) {
|
||||
if (cutoverCommittedOrThrow(handled)) {
|
||||
clearDomSelection();
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./useFileTree", () => ({
|
||||
useFileTree: () => ({
|
||||
projectDir: "",
|
||||
fileTree: [],
|
||||
setFileTree: vi.fn(),
|
||||
fileTreeLoaded: true,
|
||||
refreshFileTree: vi.fn(async () => {}),
|
||||
compositions: [],
|
||||
assets: [],
|
||||
fontAssets: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./useEditorSave", () => ({
|
||||
useEditorSave: () => ({
|
||||
saveRafRef: { current: null },
|
||||
handleContentChange: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useFileManager } from "./useFileManager";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("useFileManager project ownership", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps delayed callbacks bound to the project that created them", async () => {
|
||||
let resolveProjectARead: ((value: Response) => void) | undefined;
|
||||
const projectARead = new Promise<Response>((resolve) => {
|
||||
resolveProjectARead = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.includes("project-a") && !init?.method) return projectARead;
|
||||
if (!init?.method) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ content: "PROJECT_B" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
||||
function Probe({ projectId }: { projectId: string }) {
|
||||
captured.manager = useFileManager({
|
||||
projectId,
|
||||
showToast: vi.fn(),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe projectId="project-a/../other?x=1" />));
|
||||
const managerA = captured.manager;
|
||||
if (!managerA) throw new Error("project A manager did not render");
|
||||
const delayedRead = managerA.readProjectFile("index.html");
|
||||
|
||||
await act(async () => root.render(<Probe projectId="project-b#fragment" />));
|
||||
const managerB = captured.manager;
|
||||
if (!managerB) throw new Error("project B manager did not render");
|
||||
expect(managerB.writeProjectFile).not.toBe(managerA.writeProjectFile);
|
||||
|
||||
resolveProjectARead?.({
|
||||
ok: true,
|
||||
json: async () => ({ content: "PROJECT_A" }),
|
||||
} as Response);
|
||||
await expect(delayedRead).resolves.toBe("PROJECT_A");
|
||||
await managerA.writeProjectFile("index.html", "A_AFTER");
|
||||
await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
||||
await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
||||
expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-b%23fragment/files/index.html?optional=1",
|
||||
);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -66,38 +66,48 @@ export function useFileManager({
|
||||
|
||||
// ── Core file I/O ──
|
||||
|
||||
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
||||
return data.content;
|
||||
}, []);
|
||||
const readProjectFile = useCallback(
|
||||
async (path: string): Promise<string> => {
|
||||
if (!projectId) throw new Error("No active project");
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
||||
return data.content;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
await retryStudioSave(async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
||||
cause: error,
|
||||
});
|
||||
const writeProjectFile = useCallback(
|
||||
async (path: string, content: string): Promise<void> => {
|
||||
if (!projectId) throw new Error("No active project");
|
||||
const writeProjectId = projectId;
|
||||
await retryStudioSave(async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
||||
});
|
||||
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
||||
setEditingFile({ path, content });
|
||||
}
|
||||
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
||||
});
|
||||
if (editingPathRef.current === path) {
|
||||
setEditingFile({ path, content });
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
||||
if (editingPathRef.current === path) {
|
||||
@@ -105,16 +115,18 @@ export function useFileManager({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const response = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(path)}?optional=1`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
return typeof data.content === "string" ? data.content : "";
|
||||
}, []);
|
||||
const readOptionalProjectFile = useCallback(
|
||||
async (path: string): Promise<string> => {
|
||||
if (!projectId) throw new Error("No active project");
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
return typeof data.content === "string" ? data.content : "";
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// ── Editor save (debounced content change) ──
|
||||
|
||||
@@ -146,7 +158,7 @@ export function useFileManager({
|
||||
setEditingFile({ path, content: null });
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
||||
fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
||||
return r.json();
|
||||
@@ -179,7 +191,7 @@ export function useFileManager({
|
||||
const requestId = ++revealRequestIdRef.current;
|
||||
const controller = new AbortController();
|
||||
revealAbortRef.current = controller;
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, {
|
||||
fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -211,7 +223,7 @@ export function useFileManager({
|
||||
|
||||
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
@@ -251,11 +263,14 @@ export function useFileManager({
|
||||
content =
|
||||
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
||||
}
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
await refreshFileTree();
|
||||
handleFileSelect(path);
|
||||
@@ -273,7 +288,7 @@ export function useFileManager({
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
||||
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
@@ -295,9 +310,12 @@ export function useFileManager({
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === path) setEditingFile(null);
|
||||
await refreshFileTree();
|
||||
@@ -314,11 +332,14 @@ export function useFileManager({
|
||||
async (oldPath: string, newPath: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPath }),
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPath }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === oldPath) {
|
||||
handleFileSelect(newPath);
|
||||
@@ -338,7 +359,7 @@ export function useFileManager({
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
@@ -367,17 +388,18 @@ export function useFileManager({
|
||||
|
||||
const handleImportFonts = useCallback(
|
||||
async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return [];
|
||||
const uploaded = await uploadProjectFiles(
|
||||
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
||||
"assets/fonts",
|
||||
);
|
||||
const pid = projectIdRef.current;
|
||||
const imported = uploaded
|
||||
.filter((asset) => FONT_EXT.test(asset))
|
||||
.map((asset) => ({
|
||||
family: fontFamilyFromAssetPath(asset),
|
||||
path: asset,
|
||||
url: `/api/projects/${pid}/preview/${asset}`,
|
||||
url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`,
|
||||
}));
|
||||
importedFontAssetsRef.current = [
|
||||
...imported,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
sdkGsapDeleteAllForSelectorPersist,
|
||||
sdkAddWithKeyframesPersist,
|
||||
sdkReplaceWithKeyframesPersist,
|
||||
cutoverCommittedOrThrow,
|
||||
type CutoverDeps,
|
||||
} from "../utils/sdkCutover";
|
||||
import {
|
||||
@@ -52,7 +53,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
@@ -74,7 +75,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label: "Delete GSAP animation" },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
@@ -96,7 +97,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label: "Delete all animations for element" },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
void commitMutation(
|
||||
selection,
|
||||
@@ -162,7 +163,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP ${method} animation` },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
|
||||
await commitMutation(
|
||||
@@ -213,7 +214,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
void commitMutation(
|
||||
selection,
|
||||
@@ -256,7 +257,7 @@ export function useGsapAnimationOps({
|
||||
sdkDeps,
|
||||
{ label },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
void commitMutation(
|
||||
selection,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sdkGsapRemoveKeyframePersist,
|
||||
sdkGsapRemoveAllKeyframesPersist,
|
||||
sdkGsapConvertToKeyframesPersist,
|
||||
cutoverCommittedOrThrow,
|
||||
type CutoverDeps,
|
||||
} from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
@@ -136,7 +137,7 @@ export function useGsapKeyframeOps({
|
||||
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
|
||||
},
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
await commitMutation(selection, mutation, {
|
||||
label: `Add keyframe at ${percentage}%`,
|
||||
@@ -169,7 +170,7 @@ export function useGsapKeyframeOps({
|
||||
sdkDeps,
|
||||
toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
return commitMutation(
|
||||
selection,
|
||||
@@ -218,7 +219,7 @@ export function useGsapKeyframeOps({
|
||||
sdkDeps,
|
||||
toSdkPersistOptions(label, commitOverrides),
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
const commitOptions = commitOverrides?.skipReload
|
||||
? { label, ...commitOverrides }
|
||||
@@ -300,7 +301,7 @@ export function useGsapKeyframeOps({
|
||||
sdkDeps,
|
||||
toSdkPersistOptions("Convert to keyframes", commitOverrides),
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
return commitMutation(
|
||||
selection,
|
||||
@@ -329,7 +330,7 @@ export function useGsapKeyframeOps({
|
||||
sdkDeps,
|
||||
{ label: "Remove all keyframes" },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
sdkGsapTweenPersist,
|
||||
sdkGsapRemovePropertyPersist,
|
||||
cutoverCommittedOrThrow,
|
||||
type CutoverDeps,
|
||||
} from "../utils/sdkCutover";
|
||||
import { extractGsapScriptText } from "../utils/gsapSoftReload";
|
||||
@@ -45,6 +46,12 @@ interface SdkPropertyDeps {
|
||||
sdkSession?: Composition | null;
|
||||
sdkDeps?: CutoverDeps | null;
|
||||
activeCompPath?: string | null;
|
||||
onFlushError?: (
|
||||
error: unknown,
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
label: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useGsapPropertyDebounce(
|
||||
@@ -68,38 +75,46 @@ export function useGsapPropertyDebounce(
|
||||
const sdkRef = useRef(sdk);
|
||||
sdkRef.current = sdk;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const flushPendingPropertyEdit = useCallback(async () => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
pendingPropertyEditRef.current = null;
|
||||
const { selection, animationId, property, value } = pending;
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{
|
||||
kind: "set",
|
||||
animationId,
|
||||
properties: {
|
||||
properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"),
|
||||
const mutation = { type: "update-property", animationId, property, value };
|
||||
const label = `Edit GSAP ${property}`;
|
||||
try {
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{
|
||||
kind: "set",
|
||||
animationId,
|
||||
properties: {
|
||||
properties: mergeTweenProperties(
|
||||
sdkSession,
|
||||
animationId,
|
||||
{ [property]: value },
|
||||
"to",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
{ label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
{ type: "update-property", animationId, property, value },
|
||||
{
|
||||
label: `Edit GSAP ${property}`,
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
{ label, coalesceKey: `gsap:${animationId}:${property}` },
|
||||
);
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
await commitMutationSafely(selection, mutation, {
|
||||
label,
|
||||
coalesceKey: `gsap:${animationId}:${property}`,
|
||||
softReload: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
sdkRef.current?.onFlushError?.(error, selection, mutation, label);
|
||||
}
|
||||
}, [commitMutationSafely]);
|
||||
|
||||
const updateGsapProperty = useCallback(
|
||||
@@ -162,7 +177,7 @@ export function useGsapPropertyDebounce(
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP ${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
@@ -187,7 +202,7 @@ export function useGsapPropertyDebounce(
|
||||
sdkDeps,
|
||||
{ label: `Remove GSAP ${from ? `from-${property}` : property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
if (from) {
|
||||
commitMutationSafely(
|
||||
@@ -247,7 +262,7 @@ export function useGsapPropertyDebounce(
|
||||
coalesceKey: `gsap:${animationId}:from:${property}`,
|
||||
},
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
@@ -285,7 +300,7 @@ export function useGsapPropertyDebounce(
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP from-${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
|
||||
@@ -82,4 +82,39 @@ describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a rejected debounced flush instead of leaking an unhandled rejection", async () => {
|
||||
const error = new Error("save failed");
|
||||
const commitMutationSafely = vi.fn().mockRejectedValue(error);
|
||||
const onFlushError = vi.fn();
|
||||
let queueEdit: (() => void) | null = null;
|
||||
|
||||
function Harness() {
|
||||
const ops = useGsapPropertyDebounce(commitMutationSafely, {
|
||||
sdkSession: null,
|
||||
sdkDeps: null,
|
||||
activeCompPath: "index.html",
|
||||
onFlushError,
|
||||
});
|
||||
queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", 42);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
act(() => queueEdit?.());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
|
||||
expect(onFlushError).toHaveBeenCalledWith(
|
||||
error,
|
||||
selection,
|
||||
{ type: "update-property", animationId: "tw-1", property: "x", value: 42 },
|
||||
"Edit GSAP x",
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("../utils/studioTelemetry", () => ({
|
||||
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { MutationResult } from "./gsapScriptCommitTypes";
|
||||
import { persistSdkSerialize } from "../utils/sdkCutover";
|
||||
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
||||
|
||||
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
|
||||
@@ -214,7 +215,11 @@ type HookApi = ReturnType<typeof useGsapScriptCommits>;
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
function renderCommitHook() {
|
||||
function renderCommitHook(
|
||||
options: {
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const reloadPreview = vi.fn();
|
||||
const onCacheInvalidate = vi.fn();
|
||||
const onFileContentChanged = vi.fn();
|
||||
@@ -235,7 +240,7 @@ function renderCommitHook() {
|
||||
onFileContentChanged,
|
||||
showToast,
|
||||
sdkSession: null,
|
||||
writeProjectFile: undefined,
|
||||
writeProjectFile: options.writeProjectFile,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
return null;
|
||||
@@ -422,4 +427,45 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
||||
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serializes a legacy fallback request behind an in-flight SDK whole-file edit", async () => {
|
||||
let releaseSdkWrite: (() => void) | undefined;
|
||||
const sdkWriteGate = new Promise<void>((resolve) => {
|
||||
releaseSdkWrite = resolve;
|
||||
});
|
||||
let notifySdkWriteStarted: (() => void) | undefined;
|
||||
const sdkWriteStarted = new Promise<void>((resolve) => {
|
||||
notifySdkWriteStarted = resolve;
|
||||
});
|
||||
const writeProjectFile = vi.fn(async () => {
|
||||
notifySdkWriteStarted?.();
|
||||
await sdkWriteGate;
|
||||
});
|
||||
const deps = renderCommitHook({ writeProjectFile });
|
||||
const sdkEdit = persistSdkSerialize(() => "SDK_AFTER", "index.html", "BEFORE", {
|
||||
editHistory: { recordEdit: deps.recordEdit },
|
||||
writeProjectFile,
|
||||
readProjectFile: vi.fn(async () => "BEFORE"),
|
||||
reloadPreview: deps.reloadPreview,
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
});
|
||||
await sdkWriteStarted;
|
||||
|
||||
mockFetchResult();
|
||||
let legacyEdit: Promise<void> | undefined;
|
||||
act(() => {
|
||||
legacyEdit = deps.api.commitMutation(selection, { x: 10 }, { label: "Legacy fallback" });
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
releaseSdkWrite?.();
|
||||
await act(async () => {
|
||||
await Promise.all([sdkEdit, legacyEdit]);
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj-1/gsap-mutations/index.html",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
||||
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
||||
import {
|
||||
getStudioSaveErrorMessage,
|
||||
isStudioSaveErrorAlreadyToasted,
|
||||
markStudioSaveErrorAlreadyToasted,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import type { CutoverDeps } from "../utils/sdkCutover";
|
||||
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
||||
import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
|
||||
@@ -81,15 +87,19 @@ async function runMutationRequest(
|
||||
if (unsafeFields.length > 0) {
|
||||
showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
|
||||
if (options.skipReload) return;
|
||||
throw new Error(
|
||||
`Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
|
||||
throw markStudioSaveErrorAlreadyToasted(
|
||||
new Error(
|
||||
`Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (error instanceof GsapMutationHttpError)
|
||||
if (error instanceof GsapMutationHttpError) {
|
||||
showToast?.(formatGsapMutationRejectionToast(error), "error");
|
||||
markStudioSaveErrorAlreadyToasted(error);
|
||||
}
|
||||
if (options.skipReload) return;
|
||||
throw error;
|
||||
}
|
||||
@@ -120,6 +130,54 @@ function refreshMutationPreview(
|
||||
onCacheInvalidate();
|
||||
}
|
||||
|
||||
function isActiveCommitTarget(
|
||||
projectIdRef: { current: string | null },
|
||||
activeCompPathRef: { current: string | null },
|
||||
projectId: string,
|
||||
compositionPath: string | null,
|
||||
): boolean {
|
||||
return projectIdRef.current === projectId && activeCompPathRef.current === compositionPath;
|
||||
}
|
||||
|
||||
function syncCommittedGsapMutation({
|
||||
iframe,
|
||||
selection,
|
||||
mutation,
|
||||
targetPath,
|
||||
result,
|
||||
options,
|
||||
onFileContentChanged,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
}: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
selection: DomEditSelection;
|
||||
mutation: Record<string, unknown>;
|
||||
targetPath: string;
|
||||
result: MutationResult;
|
||||
options: CommitMutationOptions;
|
||||
onFileContentChanged?: (path: string, content: string) => void;
|
||||
forceReloadSdkSession?: () => void;
|
||||
reloadPreview: () => void;
|
||||
onCacheInvalidate: () => void;
|
||||
}): void {
|
||||
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
||||
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
||||
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
|
||||
forceReloadSdkSession?.();
|
||||
if (options.skipReload) return;
|
||||
if (result.parsed?.animations) {
|
||||
updateKeyframeCacheFromParsed(
|
||||
result.parsed.animations,
|
||||
targetPath,
|
||||
selection.id ?? undefined,
|
||||
mutation,
|
||||
);
|
||||
}
|
||||
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a soft reload and enforce the U4 invariant via the richer
|
||||
* `SoftReloadResult`, with telemetry on every non-success path so the invariant
|
||||
@@ -208,7 +266,10 @@ export function applyPreviewSync(
|
||||
|
||||
// oxfmt-ignore
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
||||
const activeProjectId = projectIdRef.current;
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
// Serializer for per-key commits (options.serializeKey). Keyed by
|
||||
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
||||
// the same animationId so their POSTs can't interleave. Held in a ref so the
|
||||
@@ -225,69 +286,115 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
});
|
||||
}, [editHistory]);
|
||||
|
||||
const finalizeSuccessfulMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
|
||||
const finalizeSuccessfulMutation = useCallback(async (projectId: string, compositionPath: string | null, selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
|
||||
if (projectIdRef.current !== projectId) return;
|
||||
const previewIsActive = isActiveCommitTarget(
|
||||
projectIdRef,
|
||||
activeCompPathRef,
|
||||
projectId,
|
||||
compositionPath,
|
||||
);
|
||||
// A no-op file write may still owe the runtime a deferred instant patch.
|
||||
if (finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview)) return;
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
if (result.changed === false) {
|
||||
if (previewIsActive) {
|
||||
finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previewIsActive) domEditSaveTimestampRef.current = Date.now();
|
||||
await recordMutationEdit(targetPath, result, options);
|
||||
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
||||
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
||||
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
|
||||
forceReloadSdkSession?.();
|
||||
if (options.skipReload) return;
|
||||
if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
|
||||
refreshMutationPreview(
|
||||
previewIframeRef.current,
|
||||
// The durable mutation belongs to the project captured when it was queued.
|
||||
// A later project must never receive its file state or preview refresh.
|
||||
if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
|
||||
syncCommittedGsapMutation({
|
||||
iframe: previewIframeRef.current,
|
||||
selection,
|
||||
mutation,
|
||||
targetPath,
|
||||
result,
|
||||
options,
|
||||
onFileContentChanged,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
);
|
||||
}, [previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
||||
});
|
||||
}, [projectIdRef, previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
||||
|
||||
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
||||
const result = await runMutationRequest([mutation], options, showToast, () =>
|
||||
mutateGsapScript(pid, targetPath, mutation),
|
||||
);
|
||||
if (!result) return;
|
||||
await finalizeSuccessfulMutation(selection, mutation, targetPath, result, options);
|
||||
}, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
const runBatchCommit = useCallback(async (calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
||||
const pid = projectIdRef.current;
|
||||
const runBatchCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
||||
const first = calls[0];
|
||||
const last = calls.at(-1);
|
||||
if (!pid || !first || !last) return;
|
||||
const targetPath = first.selection.sourceFile || activeCompPath || "index.html";
|
||||
if (!first || !last) return;
|
||||
const mutations = calls.map(({ mutation }) => mutation);
|
||||
const result = await runMutationRequest(mutations, options, showToast, () =>
|
||||
mutateGsapScriptBatch(pid, targetPath, mutations),
|
||||
);
|
||||
if (!result) return;
|
||||
await finalizeSuccessfulMutation(last.selection, last.mutation, targetPath, result, options);
|
||||
}, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
||||
// commits to the SAME file (any op type, any animation) interleave server-side,
|
||||
// so serialize per target file by default; an explicit serializeKey overrides.
|
||||
// so every legacy request takes the same project/file lock as SDK writes. An
|
||||
// explicit key adds ordering for related calls but never replaces the file lock.
|
||||
const commitMutation = useMemo<CommitMutation>(() => {
|
||||
const serializeFile = <T,>(file: string, task: () => Promise<T>): Promise<T> => {
|
||||
if (writeProjectFile) {
|
||||
return serializeStudioFileMutation(writeProjectFile, file, task);
|
||||
}
|
||||
return serializerRef.current(`gsap-file:${file}`, task);
|
||||
};
|
||||
const serializeCommit = <T,>(
|
||||
file: string,
|
||||
serializeKey: string | undefined,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const fileKey = `gsap-file:${file}`;
|
||||
const run = () => serializeFile(file, task);
|
||||
if (serializeKey && (writeProjectFile || serializeKey !== fileKey)) {
|
||||
return serializerRef.current(serializeKey, run);
|
||||
}
|
||||
return run();
|
||||
};
|
||||
const commit: CommitMutation = (selection, mutation, options) => {
|
||||
if (!activeProjectId) return Promise.resolve();
|
||||
const file = selection.sourceFile || activeCompPath || "index.html";
|
||||
const key = options.serializeKey ?? `gsap-file:${file}`;
|
||||
return serializerRef.current(key, () => runCommit(selection, mutation, options));
|
||||
return serializeCommit(file, options.serializeKey, () =>
|
||||
runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
|
||||
);
|
||||
};
|
||||
commit.batch = (calls, options) => {
|
||||
if (!activeProjectId) return Promise.resolve();
|
||||
const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
|
||||
const key = options.serializeKey ?? `gsap-file:${file}`;
|
||||
return serializerRef.current(key, () => runBatchCommit(calls, options));
|
||||
return serializeCommit(file, options.serializeKey, () =>
|
||||
runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
|
||||
);
|
||||
};
|
||||
return commit;
|
||||
}, [runCommit, runBatchCommit, activeCompPath]);
|
||||
}, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
|
||||
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
||||
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
||||
const handleGsapSaveFailure = useCallback(
|
||||
(
|
||||
error: unknown,
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
label?: string,
|
||||
) => {
|
||||
trackGsapSaveFailure(error, selection, mutation, label);
|
||||
if (!isStudioSaveErrorAlreadyToasted(error)) {
|
||||
showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
|
||||
}
|
||||
},
|
||||
[showToast, trackGsapSaveFailure],
|
||||
);
|
||||
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, handleGsapSaveFailure);
|
||||
|
||||
// One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
|
||||
// hooks' callbacks keep a stable identity (an inline literal here re-fired the
|
||||
@@ -315,23 +422,15 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
},
|
||||
[previewIframeRef, reloadPreview, onCacheInvalidate],
|
||||
);
|
||||
// Reuse the SAME per-file serializer the legacy commitMutation path uses, so
|
||||
// SDK gsap-write flushes serialize against legacy commits AND each other —
|
||||
// overlapping same-file read-modify-writes can't interleave and lose an edit.
|
||||
const serializeByFile = useCallback(
|
||||
<T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
|
||||
[],
|
||||
);
|
||||
// Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
|
||||
// exact prior content as its undo `before` (matching the style/delete paths),
|
||||
// instead of a normalized full-DOM re-emit that would reformat the whole file.
|
||||
const readProjectFileContent = useCallback(
|
||||
(path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
return readSharedProjectFileContent(pid, path);
|
||||
if (!activeProjectId) throw new Error("No active project");
|
||||
return readSharedProjectFileContent(activeProjectId, path);
|
||||
},
|
||||
[projectIdRef],
|
||||
[activeProjectId],
|
||||
);
|
||||
const sdkDeps = useMemo<CutoverDeps | null>(
|
||||
() =>
|
||||
@@ -343,8 +442,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
domEditSaveTimestampRef,
|
||||
refresh: sdkRefresh,
|
||||
compositionPath: activeCompPath,
|
||||
serialize: serializeByFile,
|
||||
readProjectFile: readProjectFileContent,
|
||||
publishSession: publishSdkSession,
|
||||
}
|
||||
: null,
|
||||
[
|
||||
@@ -354,8 +453,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
domEditSaveTimestampRef,
|
||||
sdkRefresh,
|
||||
activeCompPath,
|
||||
serializeByFile,
|
||||
readProjectFileContent,
|
||||
publishSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -363,6 +462,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
activeCompPath,
|
||||
onFlushError: handleGsapSaveFailure,
|
||||
});
|
||||
const animationOps = useGsapAnimationOps({
|
||||
projectIdRef,
|
||||
@@ -377,7 +477,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
activeCompPath,
|
||||
commitMutation,
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure,
|
||||
trackGsapSaveFailure: handleGsapSaveFailure,
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type Params = Parameters<typeof useGsapSelectionHandlers>[0];
|
||||
type Handlers = ReturnType<typeof useGsapSelectionHandlers>;
|
||||
|
||||
function makeSelection(): DomEditSelection {
|
||||
return {
|
||||
id: "box",
|
||||
hfId: "hf-box",
|
||||
selector: "#box",
|
||||
sourceFile: "index.html",
|
||||
element: document.createElement("div"),
|
||||
} as unknown as DomEditSelection;
|
||||
}
|
||||
|
||||
function makeParams(overrides: Partial<Params> = {}): Params {
|
||||
const resolved = () => vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
domEditSelection: makeSelection(),
|
||||
updateGsapProperty: vi.fn(),
|
||||
updateGsapMeta: resolved(),
|
||||
deleteGsapAnimation: resolved(),
|
||||
deleteAllForSelector: resolved(),
|
||||
addGsapAnimation: resolved(),
|
||||
addGsapProperty: resolved(),
|
||||
removeGsapProperty: resolved(),
|
||||
updateGsapFromProperty: resolved(),
|
||||
addGsapFromProperty: resolved(),
|
||||
removeGsapFromProperty: resolved(),
|
||||
addKeyframe: vi.fn(),
|
||||
addKeyframeBatch: resolved(),
|
||||
removeKeyframe: vi.fn(),
|
||||
moveKeyframe: vi.fn(),
|
||||
resizeKeyframedTween: vi.fn(),
|
||||
convertToKeyframes: resolved(),
|
||||
removeAllKeyframes: resolved(),
|
||||
handleDomManualEditsReset: vi.fn(),
|
||||
selectedGsapAnimations: [],
|
||||
showToast: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHandlers(params: Params): { handlers: () => Handlers; unmount: () => void } {
|
||||
let current: Handlers | undefined;
|
||||
function Probe() {
|
||||
current = useGsapSelectionHandlers(params);
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(<Probe />));
|
||||
return {
|
||||
handlers: () => {
|
||||
if (!current) throw new Error("Hook did not render");
|
||||
return current;
|
||||
},
|
||||
unmount: () => act(() => root.unmount()),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushRejection(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useGsapSelectionHandlers save failures", () => {
|
||||
it("surfaces a rejected animation metadata save", async () => {
|
||||
const error = new Error("write failed");
|
||||
const showToast = vi.fn();
|
||||
const rendered = renderHandlers(
|
||||
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
|
||||
);
|
||||
|
||||
act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
|
||||
await flushRejection();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
|
||||
rendered.unmount();
|
||||
});
|
||||
|
||||
it("surfaces a rejected non-debounced property save", async () => {
|
||||
const error = new Error("write failed");
|
||||
const showToast = vi.fn();
|
||||
const rendered = renderHandlers(
|
||||
makeParams({ addGsapProperty: vi.fn().mockRejectedValue(error), showToast }),
|
||||
);
|
||||
|
||||
act(() => rendered.handlers().handleGsapAddProperty("anim-1", "opacity"));
|
||||
await flushRejection();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
|
||||
rendered.unmount();
|
||||
});
|
||||
|
||||
it("does not duplicate a toast already emitted by the mutation request", async () => {
|
||||
const error = Object.assign(new Error("write failed"), { alreadyToasted: true });
|
||||
const showToast = vi.fn();
|
||||
const rendered = renderHandlers(
|
||||
makeParams({ addGsapAnimation: vi.fn().mockRejectedValue(error), showToast }),
|
||||
);
|
||||
|
||||
act(() => rendered.handlers().handleGsapAddAnimation("to"));
|
||||
await flushRejection();
|
||||
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { computeCurrentPercentage } from "./gsapDragCommit";
|
||||
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
||||
import {
|
||||
getStudioSaveErrorMessage,
|
||||
isStudioSaveErrorAlreadyToasted,
|
||||
trackStudioSaveFailure,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
|
||||
@@ -34,6 +38,7 @@ export function useGsapSelectionHandlers({
|
||||
removeAllKeyframes,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
showToast,
|
||||
}: {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
updateGsapProperty: (
|
||||
@@ -46,24 +51,24 @@ export function useGsapSelectionHandlers({
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => void;
|
||||
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
|
||||
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
|
||||
) => Promise<void>;
|
||||
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
|
||||
addGsapAnimation: (
|
||||
sel: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
time: number,
|
||||
) => Promise<void>;
|
||||
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
updateGsapFromProperty: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
prop: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
) => Promise<void>;
|
||||
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
|
||||
addKeyframe: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
@@ -104,10 +109,11 @@ export function useGsapSelectionHandlers({
|
||||
duration?: number,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
) => Promise<void>;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
}) {
|
||||
const lastSelectionRef = useRef<DomEditSelection | null>(null);
|
||||
if (domEditSelection) lastSelectionRef.current = domEditSelection;
|
||||
@@ -124,8 +130,20 @@ export function useGsapSelectionHandlers({
|
||||
targetSelector: selection.selector,
|
||||
targetSourceFile: selection.sourceFile,
|
||||
});
|
||||
if (!isStudioSaveErrorAlreadyToasted(error)) {
|
||||
showToast(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
|
||||
}
|
||||
},
|
||||
[],
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const observeGsapMutation = useCallback(
|
||||
(mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
|
||||
void mutation.catch((error) => {
|
||||
trackGsapHandlerFailure(error, selection, mutationType, label);
|
||||
});
|
||||
},
|
||||
[trackGsapHandlerFailure],
|
||||
);
|
||||
|
||||
const handleGsapUpdateProperty = useCallback(
|
||||
@@ -144,18 +162,23 @@ export function useGsapSelectionHandlers({
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
updateGsapMeta(sel, animId, updates);
|
||||
observeGsapMutation(
|
||||
updateGsapMeta(sel, animId, updates),
|
||||
sel,
|
||||
"update-meta",
|
||||
"Edit GSAP animation",
|
||||
);
|
||||
},
|
||||
[domEditSelection, updateGsapMeta],
|
||||
[domEditSelection, observeGsapMutation, updateGsapMeta],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAnimation = useCallback(
|
||||
(animId: string) => {
|
||||
const sel = domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
deleteGsapAnimation(sel, animId);
|
||||
observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
|
||||
},
|
||||
[domEditSelection, deleteGsapAnimation],
|
||||
[domEditSelection, deleteGsapAnimation, observeGsapMutation],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAllForElement = useCallback(
|
||||
@@ -163,9 +186,14 @@ export function useGsapSelectionHandlers({
|
||||
const sel = domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
trackStudioEvent("keyframe", { action: "delete_all" });
|
||||
deleteAllForSelector(sel, targetSelector);
|
||||
observeGsapMutation(
|
||||
deleteAllForSelector(sel, targetSelector),
|
||||
sel,
|
||||
"delete-all-for-selector",
|
||||
"Delete all animations for element",
|
||||
);
|
||||
},
|
||||
[domEditSelection, deleteAllForSelector],
|
||||
[domEditSelection, deleteAllForSelector, observeGsapMutation],
|
||||
);
|
||||
|
||||
const handleGsapAddAnimation = useCallback(
|
||||
@@ -186,41 +214,66 @@ export function useGsapSelectionHandlers({
|
||||
const handleGsapAddProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapProperty(domEditSelection, animId, prop);
|
||||
observeGsapMutation(
|
||||
addGsapProperty(domEditSelection, animId, prop),
|
||||
domEditSelection,
|
||||
"add-property",
|
||||
`Add GSAP ${prop}`,
|
||||
);
|
||||
},
|
||||
[domEditSelection, addGsapProperty],
|
||||
[domEditSelection, addGsapProperty, observeGsapMutation],
|
||||
);
|
||||
|
||||
const handleGsapRemoveProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapProperty(domEditSelection, animId, prop);
|
||||
observeGsapMutation(
|
||||
removeGsapProperty(domEditSelection, animId, prop),
|
||||
domEditSelection,
|
||||
"remove-property",
|
||||
`Remove GSAP ${prop}`,
|
||||
);
|
||||
},
|
||||
[domEditSelection, removeGsapProperty],
|
||||
[domEditSelection, observeGsapMutation, removeGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapUpdateFromProperty = useCallback(
|
||||
(animId: string, prop: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapFromProperty(domEditSelection, animId, prop, value);
|
||||
observeGsapMutation(
|
||||
updateGsapFromProperty(domEditSelection, animId, prop, value),
|
||||
domEditSelection,
|
||||
"update-from-property",
|
||||
`Edit GSAP from-${prop}`,
|
||||
);
|
||||
},
|
||||
[domEditSelection, updateGsapFromProperty],
|
||||
[domEditSelection, observeGsapMutation, updateGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapFromProperty(domEditSelection, animId, prop);
|
||||
observeGsapMutation(
|
||||
addGsapFromProperty(domEditSelection, animId, prop),
|
||||
domEditSelection,
|
||||
"add-from-property",
|
||||
`Add GSAP from-${prop}`,
|
||||
);
|
||||
},
|
||||
[domEditSelection, addGsapFromProperty],
|
||||
[domEditSelection, addGsapFromProperty, observeGsapMutation],
|
||||
);
|
||||
|
||||
const handleGsapRemoveFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapFromProperty(domEditSelection, animId, prop);
|
||||
observeGsapMutation(
|
||||
removeGsapFromProperty(domEditSelection, animId, prop),
|
||||
domEditSelection,
|
||||
"remove-from-property",
|
||||
`Remove GSAP from-${prop}`,
|
||||
);
|
||||
},
|
||||
[domEditSelection, removeGsapFromProperty],
|
||||
[domEditSelection, observeGsapMutation, removeGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframe = useCallback(
|
||||
@@ -354,18 +407,28 @@ export function useGsapSelectionHandlers({
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeAllKeyframes(domEditSelection, animId);
|
||||
observeGsapMutation(
|
||||
removeAllKeyframes(domEditSelection, animId),
|
||||
domEditSelection,
|
||||
"remove-all-keyframes",
|
||||
"Remove all keyframes",
|
||||
);
|
||||
},
|
||||
[domEditSelection, removeAllKeyframes],
|
||||
[domEditSelection, observeGsapMutation, removeAllKeyframes],
|
||||
);
|
||||
|
||||
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|
||||
if (!domEditSelection) return false;
|
||||
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!withKeyframes) return false;
|
||||
removeAllKeyframes(domEditSelection, withKeyframes.id);
|
||||
observeGsapMutation(
|
||||
removeAllKeyframes(domEditSelection, withKeyframes.id),
|
||||
domEditSelection,
|
||||
"remove-all-keyframes",
|
||||
"Remove all keyframes",
|
||||
);
|
||||
return true;
|
||||
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
|
||||
}, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
|
||||
|
||||
return {
|
||||
handleGsapUpdateProperty,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
||||
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushAsyncEffects(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("usePersistentEditHistory project ownership", () => {
|
||||
it("rejects a delayed project A recorder after project B becomes active", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const now = () => 100;
|
||||
const captured: { history: ReturnType<typeof usePersistentEditHistory> | null } = {
|
||||
history: null,
|
||||
};
|
||||
|
||||
function Probe({ projectId }: { projectId: string }) {
|
||||
captured.history = usePersistentEditHistory({ projectId, storage, now });
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe projectId="project-a" />));
|
||||
await flushAsyncEffects();
|
||||
const recordProjectA = captured.history?.recordEdit;
|
||||
if (!recordProjectA) throw new Error("project A history did not load");
|
||||
|
||||
await act(async () => root.render(<Probe projectId="project-b" />));
|
||||
await expect(
|
||||
recordProjectA({
|
||||
label: "Delayed A edit",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "A", after: "A2" } },
|
||||
}),
|
||||
).rejects.toThrow("inactive project project-a");
|
||||
await flushAsyncEffects();
|
||||
|
||||
const recordProjectB = captured.history?.recordEdit;
|
||||
if (!recordProjectB) throw new Error("project B history did not load");
|
||||
await act(async () => {
|
||||
await recordProjectB({
|
||||
label: "B edit",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "B", after: "B2" } },
|
||||
});
|
||||
});
|
||||
|
||||
expect(await storage.get("project-a")).toBeNull();
|
||||
expect((await storage.get("project-b"))?.undo.map((entry) => entry.label)).toEqual(["B edit"]);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyEditHistory } from "../utils/editHistory";
|
||||
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
||||
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
||||
import {
|
||||
serializeStudioFileMutation,
|
||||
serializeStudioFileMutations,
|
||||
} from "../utils/studioFileMutationCoordinator";
|
||||
import {
|
||||
createPersistentEditHistoryController,
|
||||
createPersistentEditHistoryStore,
|
||||
@@ -213,6 +217,50 @@ describe("createPersistentEditHistoryController", () => {
|
||||
expect(store.snapshot().canRedo).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for same-file mutations before checking an undo hash", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: () => 100,
|
||||
onChange: () => {},
|
||||
});
|
||||
await store.recordEdit({
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
files: { "index.html": { before: "before", after: "after" } },
|
||||
});
|
||||
|
||||
let disk = "after";
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const writeFile = vi.fn(async (_path: string, content: string) => {
|
||||
disk = content;
|
||||
});
|
||||
const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
|
||||
await blocked;
|
||||
disk = "newer-edit";
|
||||
});
|
||||
const readFile = vi.fn(async () => disk);
|
||||
const undo = store.undo({
|
||||
readFile,
|
||||
writeFile,
|
||||
serialize: (paths, task) => serializeStudioFileMutations(writeFile, paths, task),
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
release();
|
||||
await priorMutation;
|
||||
await expect(undo).resolves.toMatchObject({ ok: false, reason: "content-mismatch" });
|
||||
expect(disk).toBe("newer-edit");
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(store.snapshot().canUndo).toBe(true);
|
||||
});
|
||||
|
||||
it("returns per-file restored/previous content so the preview can soft-apply", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const store = createPersistentEditHistoryStore({
|
||||
|
||||
@@ -30,6 +30,7 @@ interface RecordEditInput {
|
||||
interface ApplyCallbacks {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
interface UsePersistentEditHistoryOptions {
|
||||
@@ -167,31 +168,32 @@ async function applyHistoryStep(
|
||||
if (!entry) {
|
||||
return { state: currentState, result: { ok: false, reason: "empty" } };
|
||||
}
|
||||
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
||||
Object.keys(entry.files),
|
||||
callbacks.readFile,
|
||||
);
|
||||
const result = transition(currentState, currentHashes, now());
|
||||
if (!result.ok) {
|
||||
const paths = Object.keys(entry.files);
|
||||
const apply = async (): Promise<{ state: EditHistoryState; result: ApplyResult }> => {
|
||||
const { currentFiles, currentHashes } = await readCurrentFileHashes(paths, callbacks.readFile);
|
||||
const result = transition(currentState, currentHashes, now());
|
||||
if (!result.ok) {
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: result.reason },
|
||||
};
|
||||
}
|
||||
await writeFilesWithRollback({
|
||||
files: result.filesToWrite,
|
||||
rollbackFiles: currentFiles,
|
||||
writeFile: callbacks.writeFile,
|
||||
});
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: result.reason },
|
||||
state: result.state,
|
||||
result: {
|
||||
ok: true,
|
||||
label: result.entry.label,
|
||||
paths: Object.keys(result.entry.files),
|
||||
files: restoredFilesMap(result.filesToWrite, currentFiles),
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeFilesWithRollback({
|
||||
files: result.filesToWrite,
|
||||
rollbackFiles: currentFiles,
|
||||
writeFile: callbacks.writeFile,
|
||||
});
|
||||
return {
|
||||
state: result.state,
|
||||
result: {
|
||||
ok: true,
|
||||
label: result.entry.label,
|
||||
paths: Object.keys(result.entry.files),
|
||||
files: restoredFilesMap(result.filesToWrite, currentFiles),
|
||||
},
|
||||
};
|
||||
return callbacks.serialize ? callbacks.serialize(paths, apply) : apply();
|
||||
}
|
||||
|
||||
export function createPersistentEditHistoryStore({
|
||||
@@ -305,11 +307,15 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const projectId = options.projectId;
|
||||
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
||||
const storeProjectIdRef = useRef<string | null>(null);
|
||||
const activeProjectIdRef = useRef(projectId);
|
||||
activeProjectIdRef.current = projectId;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const emptyState = createEmptyEditHistory();
|
||||
storeRef.current = null;
|
||||
storeProjectIdRef.current = null;
|
||||
setState(emptyState);
|
||||
setLoaded(false);
|
||||
if (!projectId) {
|
||||
@@ -327,6 +333,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
||||
now,
|
||||
onChange: setState,
|
||||
});
|
||||
storeProjectIdRef.current = projectId;
|
||||
setState(loadedState);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -338,6 +345,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
||||
now,
|
||||
onChange: setState,
|
||||
});
|
||||
storeProjectIdRef.current = projectId;
|
||||
setState(emptyState);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -349,17 +357,49 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
||||
};
|
||||
}, [now, projectId, storage]);
|
||||
|
||||
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
||||
await storeRef.current?.recordEdit(input);
|
||||
}, []);
|
||||
const recordEdit = useCallback(
|
||||
async (input: RecordEditInput) => {
|
||||
if (!projectId) return;
|
||||
if (activeProjectIdRef.current !== projectId) {
|
||||
throw new Error(`Cannot record an edit for inactive project ${projectId}`);
|
||||
}
|
||||
const store = storeRef.current;
|
||||
if (!store) return;
|
||||
if (storeProjectIdRef.current !== projectId) {
|
||||
throw new Error(`Edit history store does not belong to project ${projectId}`);
|
||||
}
|
||||
await store.recordEdit(input);
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
}, []);
|
||||
const undo = useCallback(
|
||||
async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
if (
|
||||
!projectId ||
|
||||
activeProjectIdRef.current !== projectId ||
|
||||
storeProjectIdRef.current !== projectId
|
||||
) {
|
||||
return { ok: false, reason: "empty" };
|
||||
}
|
||||
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
}, []);
|
||||
const redo = useCallback(
|
||||
async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
if (
|
||||
!projectId ||
|
||||
activeProjectIdRef.current !== projectId ||
|
||||
storeProjectIdRef.current !== projectId
|
||||
) {
|
||||
return { ok: false, reason: "empty" };
|
||||
}
|
||||
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
loaded,
|
||||
|
||||
@@ -103,17 +103,16 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
||||
return useCallback(
|
||||
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
||||
const originalContent = await readProjectFile(path);
|
||||
const comp = await openComposition(originalContent, { history: false });
|
||||
let after: string;
|
||||
try {
|
||||
mutate(comp);
|
||||
after = comp.serialize();
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
if (after === originalContent) return;
|
||||
await persistSdkSerialize(
|
||||
after,
|
||||
async (onDiskBefore) => {
|
||||
const comp = await openComposition(onDiskBefore, { history: false });
|
||||
try {
|
||||
mutate(comp);
|
||||
return comp.serialize();
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
},
|
||||
path,
|
||||
originalContent,
|
||||
{
|
||||
@@ -122,6 +121,7 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: path,
|
||||
readProjectFile,
|
||||
},
|
||||
{ label },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openComposition = vi.fn();
|
||||
|
||||
vi.mock("@hyperframes/sdk", () => ({
|
||||
openComposition: (...args: unknown[]) => openComposition(...args),
|
||||
}));
|
||||
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function fakeSession(): Composition {
|
||||
return { dispose: vi.fn() } as unknown as Composition;
|
||||
}
|
||||
|
||||
function response(content: string): Response {
|
||||
return { ok: true, json: async () => ({ content }) } as Response;
|
||||
}
|
||||
|
||||
async function flushAsyncEffects(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("useSdkSession ownership", () => {
|
||||
beforeEach(() => {
|
||||
openComposition.mockReset();
|
||||
class FakeEventSource {
|
||||
addEventListener(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("hides project A immediately while project B with the same path is still opening", async () => {
|
||||
const sessionA = fakeSession();
|
||||
const publishedA = fakeSession();
|
||||
const sessionB = fakeSession();
|
||||
let resolveProjectB: ((value: Response) => void) | undefined;
|
||||
const projectBResponse = new Promise<Response>((resolve) => {
|
||||
resolveProjectB = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) =>
|
||||
url.includes("project-b") ? projectBResponse : Promise.resolve(response("PROJECT_A")),
|
||||
),
|
||||
);
|
||||
openComposition.mockImplementation(async (content: string) =>
|
||||
content === "PROJECT_A" ? sessionA : sessionB,
|
||||
);
|
||||
|
||||
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
||||
function Probe({ projectId }: { projectId: string }) {
|
||||
captured.handle = useSdkSession(projectId, "index.html");
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe projectId="project-a" />));
|
||||
await flushAsyncEffects();
|
||||
expect(captured.handle?.session).toBe(sessionA);
|
||||
|
||||
let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
|
||||
await act(async () => {
|
||||
publication = captured.handle?.publish({
|
||||
candidate: publishedA,
|
||||
expectedSession: sessionA,
|
||||
targetPath: "index.html",
|
||||
});
|
||||
});
|
||||
expect(publication).toBe("published");
|
||||
expect(captured.handle?.session).toBe(publishedA);
|
||||
|
||||
await act(async () => root.render(<Probe projectId="project-b" />));
|
||||
expect(captured.handle?.session).toBeNull();
|
||||
expect(publishedA.dispose).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
captured.handle?.publish({
|
||||
candidate: fakeSession(),
|
||||
expectedSession: publishedA,
|
||||
targetPath: "index.html",
|
||||
}),
|
||||
).toBe("rejected-inactive-target");
|
||||
|
||||
resolveProjectB?.(response("PROJECT_B"));
|
||||
await flushAsyncEffects();
|
||||
expect(captured.handle?.session).toBe(sessionB);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
expect(sessionB.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disposes the currently published candidate when its owner unmounts", async () => {
|
||||
const opened = fakeSession();
|
||||
const published = fakeSession();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response("PROJECT_A")),
|
||||
);
|
||||
openComposition.mockResolvedValue(opened);
|
||||
|
||||
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
||||
function Probe() {
|
||||
captured.handle = useSdkSession("project-a", "index.html");
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe />));
|
||||
await flushAsyncEffects();
|
||||
expect(captured.handle?.session).toBe(opened);
|
||||
let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
|
||||
await act(async () => {
|
||||
publication = captured.handle?.publish({
|
||||
candidate: published,
|
||||
expectedSession: opened,
|
||||
targetPath: "index.html",
|
||||
});
|
||||
});
|
||||
expect(publication).toBe("published");
|
||||
expect(opened.dispose).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
expect(published.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
||||
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import type { PublishSdkSession } from "../utils/sdkCutover";
|
||||
|
||||
/**
|
||||
* Read a project file's content, or undefined on a non-2xx (optional read).
|
||||
@@ -82,6 +84,8 @@ export function shouldReloadOnFileChange(
|
||||
|
||||
export interface SdkSessionHandle {
|
||||
session: Composition | null;
|
||||
/** Atomically publish a fully persisted candidate session. */
|
||||
publish: PublishSdkSession;
|
||||
/**
|
||||
* Force a session reload immediately, bypassing the self-write suppress
|
||||
* window. Call after undo/redo writes the active composition file so the
|
||||
@@ -90,13 +94,82 @@ export interface SdkSessionHandle {
|
||||
forceReload: () => void;
|
||||
}
|
||||
|
||||
interface SdkSessionOwner {
|
||||
projectId: string;
|
||||
path: string;
|
||||
reloadToken: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
interface OwnedSdkSession extends SdkSessionOwner {
|
||||
session: Composition;
|
||||
}
|
||||
|
||||
function isSessionOwnerActive(
|
||||
owner: SdkSessionOwner | undefined,
|
||||
projectId: string | null,
|
||||
path: string | null,
|
||||
targetPath: string,
|
||||
): owner is SdkSessionOwner {
|
||||
if (!owner) return false;
|
||||
return owner.projectId === projectId && owner.path === path && owner.path === targetPath;
|
||||
}
|
||||
|
||||
function isSessionOwnerCurrent(
|
||||
owner: SdkSessionOwner,
|
||||
generation: number,
|
||||
projectId: string | null,
|
||||
path: string | null,
|
||||
reloadToken: number,
|
||||
): boolean {
|
||||
return (
|
||||
owner.generation === generation &&
|
||||
owner.projectId === projectId &&
|
||||
owner.path === path &&
|
||||
owner.reloadToken === reloadToken
|
||||
);
|
||||
}
|
||||
|
||||
function ownsExpectedSession(
|
||||
current: OwnedSdkSession | null,
|
||||
expectedOwner: SdkSessionOwner,
|
||||
expectedSession: Composition,
|
||||
reloadToken: number,
|
||||
): current is OwnedSdkSession {
|
||||
if (!current) return false;
|
||||
return (
|
||||
current.session === expectedSession &&
|
||||
current.generation === expectedOwner.generation &&
|
||||
current.reloadToken === reloadToken
|
||||
);
|
||||
}
|
||||
|
||||
function disposeSdkSession(session: Composition): void {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch (error) {
|
||||
trackStudioEvent("sdk_session_dispose_failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useSdkSession(
|
||||
projectId: string | null,
|
||||
activeCompPath: string | null,
|
||||
domEditSaveTimestampRef?: MutableRefObject<number>,
|
||||
): SdkSessionHandle {
|
||||
const [session, setSession] = useState<Composition | null>(null);
|
||||
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
||||
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
||||
const sessionOwnersRef = useRef(new WeakMap<Composition, SdkSessionOwner>());
|
||||
const generationRef = useRef(0);
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const reloadTokenRef = useRef(reloadToken);
|
||||
reloadTokenRef.current = reloadToken;
|
||||
|
||||
// ── Re-open on external change to the active composition ──
|
||||
useEffect(() => {
|
||||
@@ -135,13 +208,28 @@ export function useSdkSession(
|
||||
|
||||
// ── Open / re-open the session ──
|
||||
useEffect(() => {
|
||||
const generation = ++generationRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
// The preceding effect normally released its generation first. Clear any
|
||||
// remaining owner defensively so an invalid project/path cannot retain it.
|
||||
const previous = ownedSessionRef.current;
|
||||
ownedSessionRef.current = null;
|
||||
setOwnedSession(null);
|
||||
if (previous) disposeSdkSession(previous.session);
|
||||
|
||||
if (!projectId || !activeCompPath) {
|
||||
setSession(null);
|
||||
return;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const compRef = { current: null as Composition | null };
|
||||
const owner: SdkSessionOwner = {
|
||||
projectId,
|
||||
path: activeCompPath,
|
||||
reloadToken,
|
||||
generation,
|
||||
};
|
||||
|
||||
readProjectFileOptional(projectId, activeCompPath)
|
||||
.then(async (content) => {
|
||||
@@ -157,24 +245,77 @@ export function useSdkSession(
|
||||
const comp = await openComposition(content, { history: false });
|
||||
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
||||
if (cancelled) {
|
||||
comp.dispose();
|
||||
disposeSdkSession(comp);
|
||||
return;
|
||||
}
|
||||
compRef.current = comp;
|
||||
setSession(comp);
|
||||
if (
|
||||
!isSessionOwnerCurrent(
|
||||
owner,
|
||||
generationRef.current,
|
||||
projectIdRef.current,
|
||||
activeCompPathRef.current,
|
||||
reloadTokenRef.current,
|
||||
)
|
||||
) {
|
||||
disposeSdkSession(comp);
|
||||
return;
|
||||
}
|
||||
const displaced = ownedSessionRef.current;
|
||||
const installed = { ...owner, session: comp };
|
||||
sessionOwnersRef.current.set(comp, owner);
|
||||
ownedSessionRef.current = installed;
|
||||
setOwnedSession(installed);
|
||||
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSession(null);
|
||||
if (!cancelled && generationRef.current === generation) setOwnedSession(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// No queue to flush; dispose only. (Flushing here would serialize the
|
||||
// pre-undo in-memory doc and race the revert write on undo/redo reload.)
|
||||
compRef.current?.dispose();
|
||||
// Publication preserves this generation, so cleanup releases whichever
|
||||
// session it currently owns (the initially opened one or its candidate).
|
||||
const owned = ownedSessionRef.current;
|
||||
if (owned?.generation === generation) {
|
||||
ownedSessionRef.current = null;
|
||||
disposeSdkSession(owned.session);
|
||||
}
|
||||
};
|
||||
}, [projectId, activeCompPath, reloadToken]);
|
||||
|
||||
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
||||
return { session, forceReload };
|
||||
const publish = useCallback<PublishSdkSession>(({ candidate, expectedSession, targetPath }) => {
|
||||
const expectedOwner = sessionOwnersRef.current.get(expectedSession);
|
||||
const current = ownedSessionRef.current;
|
||||
if (
|
||||
!isSessionOwnerActive(
|
||||
expectedOwner,
|
||||
projectIdRef.current,
|
||||
activeCompPathRef.current,
|
||||
targetPath,
|
||||
)
|
||||
) {
|
||||
return "rejected-inactive-target";
|
||||
}
|
||||
if (!ownsExpectedSession(current, expectedOwner, expectedSession, reloadTokenRef.current)) {
|
||||
// The durable write won, but another session was installed for this same
|
||||
// path before publication. Its self-write echo will be suppressed, so
|
||||
// explicitly re-open it from disk instead of leaving it stale.
|
||||
setReloadToken((t) => t + 1);
|
||||
return "rejected-active-target";
|
||||
}
|
||||
const next: OwnedSdkSession = { ...current, session: candidate };
|
||||
sessionOwnersRef.current.set(candidate, current);
|
||||
ownedSessionRef.current = next;
|
||||
setOwnedSession(next);
|
||||
if (current.session !== candidate) disposeSdkSession(current.session);
|
||||
return "published";
|
||||
}, []);
|
||||
const session =
|
||||
ownedSession?.projectId === projectId &&
|
||||
ownedSession.path === activeCompPath &&
|
||||
ownedSession.reloadToken === reloadToken
|
||||
? ownedSession.session
|
||||
: null;
|
||||
return { session, publish, forceReload };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type MutableRefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { PublishSdkSession } from "../utils/sdkCutover";
|
||||
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
|
||||
|
||||
export interface UseSlideshowPersistParams {
|
||||
@@ -16,6 +17,8 @@ export interface UseSlideshowPersistParams {
|
||||
}) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
/** Publish a fully persisted candidate SDK session. */
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
/**
|
||||
* When provided, rapid writes with the same key coalesce through the
|
||||
* save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
|
||||
@@ -33,6 +36,7 @@ export function useSlideshowPersist({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
publishSdkSession,
|
||||
coalesceKey,
|
||||
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
|
||||
return useCallback(
|
||||
@@ -42,7 +46,6 @@ export function useSlideshowPersist({
|
||||
const originalContent = await readProjectFile(path);
|
||||
await persistSlideshowManifest({
|
||||
manifest,
|
||||
sdkSession,
|
||||
originalContent,
|
||||
targetPath: path,
|
||||
deps: {
|
||||
@@ -50,6 +53,8 @@ export function useSlideshowPersist({
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
readProjectFile,
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
coalesceKey,
|
||||
});
|
||||
@@ -62,6 +67,7 @@ export function useSlideshowPersist({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
publishSdkSession,
|
||||
coalesceKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1108,6 +1108,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
||||
stubProjectFetch(ROLLBACK_SOURCE);
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const showToast = vi.fn();
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
@@ -1116,8 +1117,9 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
reloadPreview: vi.fn(),
|
||||
showToast,
|
||||
});
|
||||
return { iframe, clip, hook, writeError };
|
||||
return { iframe, clip, hook, showToast, writeError };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1137,6 +1139,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
||||
await flushAsyncWork();
|
||||
});
|
||||
expect(rejection).toBe(ctx.writeError);
|
||||
expect(ctx.showToast).toHaveBeenCalledWith("write failed", "error");
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(ctx.iframe)).toBe("4");
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
} from "./timelineTrackVisibility";
|
||||
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
||||
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
||||
import { sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
@@ -53,6 +54,7 @@ export function useTimelineEditing({
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
forceReloadSdkSession,
|
||||
handleDomZIndexReorderCommitRef,
|
||||
}: UseTimelineEditingOptions) {
|
||||
@@ -121,10 +123,10 @@ export function useTimelineEditing({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
});
|
||||
|
||||
const handleTimelineElementMove = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(element: TimelineElement, updates: TimelineMoveUpdates) => {
|
||||
@@ -215,10 +217,11 @@ export function useTimelineEditing({
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
||||
// restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return moveFallback();
|
||||
).then((result) => {
|
||||
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
||||
});
|
||||
}
|
||||
return moveFallback();
|
||||
@@ -226,6 +229,7 @@ export function useTimelineEditing({
|
||||
.catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root.
|
||||
rollbackDuration();
|
||||
showToast(getStudioSaveErrorMessage(error), "error");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
@@ -236,12 +240,14 @@ export function useTimelineEditing({
|
||||
enqueueEdit,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
recordEdit,
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
timelineElements,
|
||||
handleDomZIndexReorderCommitRef,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -255,9 +261,6 @@ export function useTimelineEditing({
|
||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
||||
];
|
||||
// Patch the live playback-start/media-start attr too, or a resize that
|
||||
// trims the playback start leaves the preview showing the old in-point
|
||||
// until the next reload (the persisted patch handles it via pbs below).
|
||||
if (updates.playbackStart != null) {
|
||||
const liveAttr =
|
||||
element.playbackStartAttr === "playback-start"
|
||||
@@ -319,15 +322,17 @@ export function useTimelineEditing({
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing
|
||||
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
{ label: "Resize timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return resizeFallback();
|
||||
).then((result) => {
|
||||
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
||||
})
|
||||
: resizeFallback();
|
||||
return persistDone.catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root.
|
||||
rollbackDuration();
|
||||
showToast(getStudioSaveErrorMessage(error), "error");
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
@@ -336,10 +341,12 @@ export function useTimelineEditing({
|
||||
enqueueEdit,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
recordEdit,
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MutableRefObject, RefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { PublishSdkSession } from "../utils/sdkCutover";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
@@ -40,6 +41,8 @@ export interface UseTimelineEditingOptions {
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
||||
sdkSession?: Composition | null;
|
||||
/** Publish a fully persisted candidate SDK session. */
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
|
||||
import {
|
||||
cutoverCommittedOrThrow,
|
||||
sdkTimingBatchPersist,
|
||||
type PublishSdkSession,
|
||||
} from "../utils/sdkCutover";
|
||||
import {
|
||||
buildTimelineMoveTimingPatch,
|
||||
buildTimelineResizeTimingPatch,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
shiftGsapPositions,
|
||||
syncPreviewContentDuration,
|
||||
} from "./timelineTimingSync";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
export interface TimelineGroupMoveChange {
|
||||
element: TimelineElement;
|
||||
@@ -53,6 +58,7 @@ interface UseTimelineGroupEditingOptions {
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
sdkSession?: Composition | null;
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
}
|
||||
@@ -108,6 +114,7 @@ export function useTimelineGroupEditing({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
publishSdkSession,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
}: UseTimelineGroupEditingOptions) {
|
||||
@@ -189,7 +196,7 @@ export function useTimelineGroupEditing({
|
||||
input.eligible &&
|
||||
input.sdkChanges.every((change) => change !== null);
|
||||
if (!canUseSdk) return false;
|
||||
return sdkTimingBatchPersist(
|
||||
const result = await sdkTimingBatchPersist(
|
||||
input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
@@ -200,14 +207,17 @@ export function useTimelineGroupEditing({
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
{ label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
|
||||
);
|
||||
return cutoverCommittedOrThrow(result);
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
projectIdRef,
|
||||
publishSdkSession,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
@@ -313,6 +323,7 @@ export function useTimelineGroupEditing({
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
rollbackDuration();
|
||||
showToast(getStudioSaveErrorMessage(error), "error");
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
@@ -324,6 +335,7 @@ export function useTimelineGroupEditing({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
trySdkBatchPersist,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -419,6 +431,7 @@ export function useTimelineGroupEditing({
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
rollbackDuration();
|
||||
showToast(getStudioSaveErrorMessage(error), "error");
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
@@ -430,6 +443,7 @@ export function useTimelineGroupEditing({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
trySdkBatchPersist,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { persistSdkSerialize } from "../utils/sdkCutover";
|
||||
import { cutoverCommittedOrThrow, persistSdkCandidateMutation } from "../utils/sdkCutover";
|
||||
import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
|
||||
|
||||
/** Same single-writer dependency set the slideshow persist path uses. */
|
||||
@@ -21,6 +21,7 @@ export function useVariablesPersist({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
publishSdkSession,
|
||||
}: UseVariablesPersistParams): (
|
||||
label: string,
|
||||
mutate: (session: Composition) => void,
|
||||
@@ -30,11 +31,8 @@ export function useVariablesPersist({
|
||||
if (!sdkSession) return false;
|
||||
const path = activeCompPath ?? "index.html";
|
||||
const originalContent = await readProjectFile(path);
|
||||
mutate(sdkSession);
|
||||
const after = sdkSession.serialize();
|
||||
if (after === originalContent) return false;
|
||||
await persistSdkSerialize(
|
||||
after,
|
||||
const result = await persistSdkCandidateMutation(
|
||||
sdkSession,
|
||||
path,
|
||||
originalContent,
|
||||
{
|
||||
@@ -43,10 +41,13 @@ export function useVariablesPersist({
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile,
|
||||
publishSession: publishSdkSession,
|
||||
},
|
||||
mutate,
|
||||
{ label },
|
||||
);
|
||||
return true;
|
||||
return cutoverCommittedOrThrow(result);
|
||||
},
|
||||
[
|
||||
sdkSession,
|
||||
@@ -56,6 +57,7 @@ export function useVariablesPersist({
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
publishSdkSession,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user