import { useCallback, useMemo, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; 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"; import { createKeyedSerializer } from "./serializeByKey"; import { GsapMutationHttpError, formatGsapMutationRejectionToast, readJsonResponseBody, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, CommitMutationCall, CommitMutationOptions, GsapScriptCommitsParams, MutationResult, } from "./gsapScriptCommitTypes"; import { useGsapAnimationOps } from "./useGsapAnimationOps"; import { useGsapArcPathOps } from "./useGsapArcPathOps"; import { useGsapKeyframeOps } from "./useGsapKeyframeOps"; import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce"; import { useGsapSaveFailureTelemetry, useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; async function mutateGsapScript( projectId: string, sourceFile: string, mutation: Record, ): Promise { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(mutation), }, ); if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res)); const result = (await res.json()) as MutationResult; if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`); return result; } async function mutateGsapScriptBatch( projectId: string, sourceFile: string, mutations: Record[], ): Promise { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mutations }), }, ); if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res)); const result = (await res.json()) as MutationResult; if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`); return result; } type ShowToast = (message: string, tone?: "error" | "info") => void; function showUnchangedMutationFeedback( mutations: Record[], result: MutationResult, showToast: ShowToast | undefined, ): void { if (result.changed !== false || mutations.length !== 1) return; if (mutations[0]?.type === "move-keyframe") { showToast?.("A keyframe already exists at that time", "info"); } } async function runMutationRequest( mutations: Record[], options: CommitMutationOptions, showToast: ShowToast | undefined, request: () => Promise, ): Promise { const unsafeFields = mutations.flatMap((mutation) => findUnsafeMutationValues(mutation)); if (unsafeFields.length > 0) { showToast?.("Couldn't read element layout — try again at a different playhead time", "error"); if (options.skipReload) return; throw markStudioSaveErrorAlreadyToasted( new Error( `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`, ), ); } try { const result = await request(); showUnchangedMutationFeedback(mutations, result, showToast); return result; } catch (error) { if (error instanceof GsapMutationHttpError) { showToast?.(formatGsapMutationRejectionToast(error), "error"); markStudioSaveErrorAlreadyToasted(error); } if (options.skipReload) return; throw error; } } function finishUnchangedMutation( iframe: HTMLIFrameElement | null, result: MutationResult, options: CommitMutationOptions, reloadPreview: () => void, ): boolean { if (result.changed !== false) return false; if (!options.skipReload && options.instantPatch) { applyPreviewSync(iframe, result, options, reloadPreview); } return true; } function refreshMutationPreview( iframe: HTMLIFrameElement | null, result: MutationResult, options: CommitMutationOptions, reloadPreview: () => void, onCacheInvalidate: () => void, ): void { options.beforeReload?.(); applyPreviewSync(iframe, result, options, reloadPreview); 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; 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 * is observable in production, not just asserted in tests: * * - `"cannot-soft-reload"` (PERMANENT/STRUCTURAL: no gsap runtime, no rebind * hook, no scopable key, no script element, or the sync re-run threw) → * escalate to a full `reloadPreview()`; the preview is genuinely stale/broken. * - `"verify-failed"` (TRANSIENT: re-run happened, `__timelines` momentarily * empty) → do NOT escalate; the live `gsap.set` already shows the correct value * and a remount would re-flash the WebGL context + revert subcomp keyframes. * - `"applied"` → success (or deferred to async plugin load; `onAsyncFailure` * covers the CDN-error escalation). */ function softReloadOrEscalate( iframe: HTMLIFrameElement | null, scriptText: string, reloadPreview: () => void, origin: "preview_sync" | "sdk_refresh", authoredHtml?: string, ): void { // Seek the rebuilt timeline to the studio's own authoritative scrub position, // not the iframe's raw `__player.getTime()` — see the comment in // applySoftReload for why the two can desync after a keyframe-node drag. const currentTime = usePlayerStore.getState().currentTime; const result: SoftReloadResult = applySoftReload(iframe, scriptText, { onAsyncFailure: reloadPreview, currentTimeOverride: currentTime, authoredHtml, }); if (result === "applied") return; trackStudioEvent("gsap_soft_reload_outcome", { origin, result, escalated: result === "cannot-soft-reload", }); // PERMANENT failure: the preview can't be soft-updated → full reload. TRANSIENT // "verify-failed" is suppressed (live state is correct). if (result === "cannot-soft-reload") reloadPreview(); } /** * Sync the preview after a persisted commit. For a value-only edit * (`options.instantPatch`), try the in-place runtime patch first: on success the * preview is already correct, so we skip the reload entirely (instant). On `false` * — or when no `instantPatch` is supplied — fall back to the existing soft/full * reload. Pure (no React) so `runCommit`'s preview-sync decision is unit-testable. */ export function applyPreviewSync( iframe: HTMLIFrameElement | null, result: MutationResult, options: CommitMutationOptions, reloadPreview: () => void, ): void { if (options.instantPatch) { const patched = patchRuntimeTweenInPlace( iframe, options.instantPatch.selector, options.instantPatch.change, ); // Patched in place — element is already correct on screen; no reload needed. if (patched) return; // The instant path couldn't patch in place — record the fallback so we can // track how often the fast path misses before the soft/full reload below. trackStudioEvent("gsap_instant_patch_fallback", { selector: options.instantPatch.selector }); // Fall through to the soft/full reload path below. } if (options.softReload && result.scriptText) { // A soft-reloadable edit escalates to a full iframe remount ONLY on the // PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/ // broken). The TRANSIENT "verify-failed" does NOT escalate — the value is // already correct on screen, and a remount re-flashes the WebGL context AND // re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin // load failure escalates separately via `onAsyncFailure`. softReloadOrEscalate( iframe, result.scriptText, reloadPreview, "preview_sync", result.after ?? undefined, ); } else { reloadPreview(); } } // oxfmt-ignore // fallow-ignore-next-line complexity 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 // chain survives re-renders. const serializerRef = useRef(createKeyedSerializer()); const recordMutationEdit = useCallback(async (targetPath: string, result: MutationResult, options: CommitMutationOptions) => { if (result.before == null || result.after == null) return; await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, coalesceMs: options.coalesceMs, files: { [targetPath]: { before: result.before, after: result.after } }, }); }, [editHistory]); const finalizeSuccessfulMutation = useCallback(async (projectId: string, compositionPath: string | null, selection: DomEditSelection, mutation: Record, 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 (result.changed === false) { if (previewIsActive) { finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview); } return; } if (previewIsActive) domEditSaveTimestampRef.current = Date.now(); await recordMutationEdit(targetPath, result, options); // 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, }); }, [projectIdRef, previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]); const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const result = await runMutationRequest([mutation], options, showToast, () => mutateGsapScript(pid, targetPath, mutation), ); if (!result) return; await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options); }, [showToast, finalizeSuccessfulMutation]); 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 (!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(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 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(() => { const serializeFile = (file: string, task: () => Promise): Promise => { if (writeProjectFile) { return serializeStudioFileMutation(writeProjectFile, file, task); } return serializerRef.current(`gsap-file:${file}`, task); }; const serializeCommit = ( file: string, serializeKey: string | undefined, task: () => Promise, ): Promise => { 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"; 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"; return serializeCommit(file, options.serializeKey, () => runBatchCommit(activeProjectId, activeCompPath, file, calls, options), ); }; return commit; }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const handleGsapSaveFailure = useCallback( ( error: unknown, selection: DomEditSelection, mutation: Record, 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 // property-debounce flush on every render). refresh() soft-reloads (preserving // the playhead) and invalidates the panel cache, matching the server path. const sdkRefresh = useCallback( (after: string) => { // extractGsapScriptText returns null when zero/multiple GSAP scripts are // present — that's an ambiguous/structural change that genuinely needs a full // reload. But a SINGLE-script soft-reloadable edit must not escalate to a full // remount even if applySoftReload reports failure (same U4 invariant as // applyPreviewSync): the live state is already correct, and a remount re-inlines // subcomps + reverts their keyframes. const script = extractGsapScriptText(after); if (script) { // Soft-reload in place. reloadPreview is the ASYNC-failure escalation — a // plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a // synchronous "verify-failed" (transient empty __timelines) does NOT escalate, // but a "cannot-soft-reload" (structural failure) does. softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after); } else { reloadPreview(); } onCacheInvalidate(); }, [previewIframeRef, reloadPreview, onCacheInvalidate], ); // 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 => { if (!activeProjectId) throw new Error("No active project"); return readSharedProjectFileContent(activeProjectId, path); }, [activeProjectId], ); const sdkDeps = useMemo( () => writeProjectFile ? { editHistory: { recordEdit: editHistory.recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef, refresh: sdkRefresh, compositionPath: activeCompPath, readProjectFile: readProjectFileContent, publishSession: publishSdkSession, } : null, [ editHistory.recordEdit, writeProjectFile, reloadPreview, domEditSaveTimestampRef, sdkRefresh, activeCompPath, readProjectFileContent, publishSdkSession, ], ); const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { sdkSession, sdkDeps, activeCompPath, onFlushError: handleGsapSaveFailure, }); const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession, sdkDeps, }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure: handleGsapSaveFailure, sdkSession, sdkDeps, }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; }