fix(studio): order the timing write after the z-index commit on a diagonal drag

A drag that both moved a clip in time and restacked it fired two writers on
the same file via separate queues (a targeted z-index patch and a full-file
timing overwrite), so the overwrite could clobber the just-persisted z-index
and the restack silently vanished after reload. The move now awaits the
z-index commit before persisting timing, giving the file one ordered writer
per gesture. Adds a regression test that gates the commit and asserts the
timing write waits.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 00:04:35 -04:00
parent 6f0bf75b69
commit 51dd8a0753
5 changed files with 133 additions and 55 deletions
@@ -39,12 +39,12 @@ export function applyTimelineStackingReorder(input: {
iframe: HTMLIFrameElement | null;
activeCompPath: string | null;
commit: TimelineZIndexReorderCommit | null | undefined;
}): void {
}): Promise<void> {
// 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;
}
/**
@@ -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<Promise<void>> = [];
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],
);
@@ -77,7 +77,7 @@ function timelineElement(input: {
function renderTimelineEditingHook(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
onZIndexCommit: (entries: ZIndexEntry[]) => void;
onZIndexCommit: (entries: ZIndexEntry[]) => Promise<void>;
projectId?: string | null;
writeProjectFile?: (path: string, content: string) => Promise<void>;
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<void>>().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<void>>().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<void>>().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<void>>().mockResolvedValue(undefined);
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(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<void>((resolve) => {
releaseCommit = resolve;
});
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) {
return jsonResponse({
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
});
}
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<unknown>;
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();
});
});
+30 -24
View File
@@ -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,
@@ -10,6 +10,8 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}
// 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<void>;
export interface UseTimelineEditingOptions {
projectId: string | null;