fix(studio): make sdk cutover transactional (#2155)

This commit is contained in:
James Russo
2026-07-15 10:29:22 -04:00
committed by GitHub
parent 7d21cc9b8a
commit 42055296ee
45 changed files with 2481 additions and 654 deletions
@@ -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,
+17 -14
View File
@@ -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
+10 -9
View File
@@ -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());
});
});
+82 -60
View File
@@ -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" }),
);
});
});
+150 -50
View File
@@ -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();
});
});
+155 -14
View File
@@ -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,
],
);
}