diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index cbe4fcf6d..3f2d9a15b 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -39,12 +39,12 @@ export function applyTimelineStackingReorder(input: { iframe: HTMLIFrameElement | null; activeCompPath: string | null; commit: TimelineZIndexReorderCommit | null | undefined; -}): void { +}): Promise { // Audio has no visual stacking; a vertical drag on it must never write z-index. - if (input.element.tag === "audio") return; + if (input.element.tag === "audio") return Promise.resolve(); const intent = input.stackingReorder ?? null; - if (intent == null || intent.zIndexChanges.length === 0) return; + if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve(); // Resolve each change's live element from the change's OWN locator (the intent // is self-contained), falling back to the top-level element list. Sub-comp @@ -75,7 +75,7 @@ export function applyTimelineStackingReorder(input: { const selector = change.selector ?? sibling?.selector; const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex; const element = findLive(domId, selector, selectorIndex); - if (!isHTMLElement(element)) return; + if (!isHTMLElement(element)) return Promise.resolve(); if (getElementZIndex(element) === change.zIndex) continue; commitEntries.push({ element, @@ -88,12 +88,13 @@ export function applyTimelineStackingReorder(input: { }); } - if (commitEntries.length === 0) return; - input.commit?.(commitEntries); + if (commitEntries.length === 0) return Promise.resolve(); + const persisted = input.commit?.(commitEntries) ?? Promise.resolve(); const store = usePlayerStore.getState(); for (const entry of commitEntries) { store.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true }); } + return persisted; } /** diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index f064d8c18..05f5e3813 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -170,13 +170,14 @@ export function useElementLifecycleOps({ sourceFile: string; }>, ) => { - if (entries.length === 0) return; + if (entries.length === 0) return Promise.resolve(); // Resolver shadow (telemetry-only, decoupled from cutover): record whether // the SDK resolves each reordered element — the reorderElements op's targets. onReorderShadow?.( entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null), ); const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; + const saves: Array> = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; entry.element.style.zIndex = String(entry.zIndex); @@ -192,23 +193,28 @@ export function useElementLifecycleOps({ } catch { /* cross-origin or detached — skip */ } - void commitPositionPatchToHtml( - { - element: entry.element, - id: entry.id ?? null, - hfId: readHfId(entry.element), - selector: entry.selector, - selectorIndex: entry.selectorIndex, - sourceFile: entry.sourceFile, - } as unknown as DomEditSelection, - patches, - { - label: "Reorder layers", - coalesceKey, - skipRefresh: i < entries.length - 1, - }, - ).catch(() => undefined); + saves.push( + commitPositionPatchToHtml( + { + element: entry.element, + id: entry.id ?? null, + hfId: readHfId(entry.element), + selector: entry.selector, + selectorIndex: entry.selectorIndex, + sourceFile: entry.sourceFile, + } as unknown as DomEditSelection, + patches, + { + label: "Reorder layers", + coalesceKey, + skipRefresh: i < entries.length - 1, + }, + ).catch(() => undefined), + ); } + // Resolves once every z-index patch is persisted so a same-file timing write + // can be ordered after it (see applyTimelineStackingReorder callers). + return Promise.all(saves).then(() => undefined); }, [commitPositionPatchToHtml, onReorderShadow], ); diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index 3ac0cabbf..e5b56a544 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -77,7 +77,7 @@ function timelineElement(input: { function renderTimelineEditingHook(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; - onZIndexCommit: (entries: ZIndexEntry[]) => void; + onZIndexCommit: (entries: ZIndexEntry[]) => Promise; projectId?: string | null; writeProjectFile?: (path: string, content: string) => Promise; recordEdit?: (input: { @@ -249,7 +249,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { const { move, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, - onZIndexCommit: vi.fn(), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit, @@ -315,7 +315,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { const { resize, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, - onZIndexCommit: vi.fn(), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit, @@ -356,7 +356,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { ]); const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, back], iframe, @@ -394,7 +394,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { ]); const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" }); - const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, music], iframe, @@ -427,7 +427,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { const front = timelineElement({ id: "front", track: 0, zIndex: 2 }); const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, back, dragged], iframe, @@ -496,7 +496,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => { const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async () => {}); const reloadPreview = vi.fn(); @@ -546,4 +546,67 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + + it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => { + const iframe = createPreviewIframe([ + { id: "clip", track: 0, style: "position: relative; z-index: 0" }, + ]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + // Gate the z-index commit so we can observe whether the timing write waits. + let releaseCommit!: () => void; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; + }); + const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockReturnValue(commitGate); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) { + return jsonResponse({ + content: '
', + }); + } + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: commit, + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + }); + + // Diagonal drag: both a time move (start change) and a restack (z-index change). + let movePromise!: Promise; + await act(async () => { + movePromise = move(clip, { + start: 1.25, + track: clip.track, + stackingReorder: { + contextKey: "root", + placement: { type: "onto", layerId: "layer-clip" }, + zIndexChanges: [{ key: "clip", zIndex: 5 }], + }, + }); + await flushAsyncWork(); + }); + + // The z-index commit is in flight but gated; the full-file timing write must + // not have run yet, or it would overwrite the file without the z-index change. + expect(commit).toHaveBeenCalledTimes(1); + expect(writeProjectFile).not.toHaveBeenCalled(); + + // Release the z-index commit → the timing write now proceeds, on top of it. + await act(async () => { + releaseCommit(); + await movePromise; + await flushAsyncWork(); + }); + expect(writeProjectFile).toHaveBeenCalled(); + + unmount(); + }); }); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 1f6a4784a..9dcb59e71 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -134,7 +134,7 @@ export function useTimelineEditing({ ]); } - applyTimelineStackingReorder({ + const reorderDone = applyTimelineStackingReorder({ element, targetTrack: updates.track, stackingReorder: updates.stackingReorder, @@ -144,7 +144,7 @@ export function useTimelineEditing({ commit: handleDomZIndexReorderCommitRef?.current, }); - if (!startChanged) return; + if (!startChanged) return reorderDone; const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); @@ -171,28 +171,34 @@ export function useTimelineEditing({ }); }); const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); - if (sdkSession && element.hfId && !needsExtension) { - return sdkTimingPersist( - element.hfId, - targetPath, - { start: updates.start }, - sdkSession, - { - editHistory: { recordEdit }, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - compositionPath: activeCompPath, - // 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), - }, - { label: "Move timeline clip", coalesceKey }, - ).then((handled) => { - if (!handled) return moveFallback(); - }); - } - return moveFallback(); + // The z-index reorder above and this timing write target the same file on + // separate save queues, and the timing write is a full-file overwrite. Order + // it after the reorder so it reads disk with the z-index already applied and + // can't clobber it — one ordered writer per gesture (diagonal move+restack). + return reorderDone.then(() => { + if (sdkSession && element.hfId && !needsExtension) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + // 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), + }, + { label: "Move timeline clip", coalesceKey }, + ).then((handled) => { + if (!handled) return moveFallback(); + }); + } + return moveFallback(); + }); }, [ previewIframeRef, diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index 2e121275b..0bd0700c1 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -10,6 +10,8 @@ interface RecordEditInput { files: Record; } +// Resolves once the z-index patches are persisted, so a caller that also writes +// the same file (e.g. a timing move) can order its write after this one. export type TimelineZIndexReorderCommit = ( entries: Array<{ element: HTMLElement; @@ -19,7 +21,7 @@ export type TimelineZIndexReorderCommit = ( selectorIndex?: number; sourceFile: string; }>, -) => void; +) => Promise; export interface UseTimelineEditingOptions { projectId: string | null;