fix(studio): address PR review — restore rollback, fix probe race, harden edit suppression

Restore rollback path: enqueueEdit now returns the queued promise so
Promise.resolve(handler(...)).catch(rollback) in useTimelineClipDrag
fires correctly on save failure. Handlers return the promise chain.

Fix lost-update race in probe enrichment: use zustand's functional
setState so concurrent probe completions each read the latest state
atomically instead of all reading the same stale snapshot.

Harden file-change suppression: pendingTimelineEditPathRef is now a
Set<string> with exact-match lookup instead of single-slot + endsWith.
Multiple concurrent edits on different files are all suppressed correctly.

Remove dead canOffsetTrimClipStart function and its tests — no longer
called after the capability gate simplification.

Document runtime sync mechanism: added comment explaining that the
runtime re-reads data attributes on each sync tick (init.ts:1324-1368).

Fix comment wording in patchIframeDomTiming catch block.
This commit is contained in:
Miguel Ángel
2026-05-19 20:59:45 -04:00
parent beb807493c
commit 366fcc2a64
6 changed files with 39 additions and 85 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ export function StudioApp() {
});
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const pendingTimelineEditPathRef = useRef<string | null>(null);
const pendingTimelineEditPathRef = useRef(new Set<string>());
const reloadPreview = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
@@ -30,7 +30,7 @@ interface UsePreviewPersistenceParams {
domEditSaveTimestampRef: React.MutableRefObject<number>;
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
* events for these paths are always suppressed since the preview is already up-to-date. */
pendingTimelineEditPathRef?: React.MutableRefObject<string | null>;
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
/** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void;
}
@@ -167,9 +167,8 @@ export function usePreviewPersistence({
const changedPath = readStudioFileChangePath(payload);
if (!changedPath) return;
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
const pendingPath = pendingTimelineEditPathRef?.current;
if (pendingPath && changedPath.endsWith(pendingPath)) {
pendingTimelineEditPathRef!.current = null;
if (pendingTimelineEditPathRef?.current.has(changedPath)) {
pendingTimelineEditPathRef.current.delete(changedPath);
return;
}
if (!recentDomEditSave) {
+28 -25
View File
@@ -39,7 +39,7 @@ interface UseTimelineEditingOptions {
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
pendingTimelineEditPathRef: React.MutableRefObject<string | null>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
}
@@ -55,6 +55,9 @@ function buildPatchTarget(element: { domId?: string; selector?: string; selector
return null;
}
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
@@ -71,7 +74,7 @@ function patchIframeDomTiming(
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
// Cross-origin or mid-navigation — safe to ignore, file is already saved.
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
@@ -112,7 +115,7 @@ interface PersistTimelineEditInput {
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<string | null>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
}
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
@@ -129,7 +132,7 @@ async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<voi
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
}
input.pendingTimelineEditPathRef.current = targetPath;
input.pendingTimelineEditPathRef.current.add(targetPath);
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
@@ -183,26 +186,26 @@ export function useTimelineEditing({
element: TimelineElement,
label: string,
buildPatches: PersistTimelineEditInput["buildPatches"],
) => {
): Promise<void> => {
const pid = projectIdRef.current;
if (!pid) return;
editQueueRef.current = editQueueRef.current
.then(() =>
persistTimelineEdit({
projectId: pid,
element,
activeCompPath,
label,
buildPatches,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}),
)
.catch((error) => {
console.error(`[Timeline] Failed to persist: ${label}`, error);
});
if (!pid) return Promise.resolve();
const queued = editQueueRef.current.then(() =>
persistTimelineEdit({
projectId: pid,
element,
activeCompPath,
label,
buildPatches,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}),
);
editQueueRef.current = queued.catch((error) => {
console.error(`[Timeline] Failed to persist: ${label}`, error);
});
return queued;
},
[
activeCompPath,
@@ -219,7 +222,7 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
enqueueEdit(element, "Move timeline clip", (original, target) => {
return enqueueEdit(element, "Move timeline clip", (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
@@ -244,7 +247,7 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
]);
enqueueEdit(element, "Resize timeline clip", (original, target) => {
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
type: "attribute",
@@ -4,7 +4,6 @@ import {
buildPromptCopyText,
buildTimelineElementAgentPrompt,
buildTimelineAgentPrompt,
canOffsetTrimClipStart,
getTimelineEditCapabilities,
hasPatchableTimelineTarget,
resolveBlockedTimelineEditIntent,
@@ -158,42 +157,6 @@ describe("resolveTimelineMove", () => {
});
});
describe("canOffsetTrimClipStart", () => {
it("allows front trim for clips that carry playback offset metadata", () => {
expect(
canOffsetTrimClipStart({
tag: "div",
playbackStartAttr: "media-start",
}),
).toBe(true);
});
it("allows front trim for media clips with source duration metadata", () => {
expect(
canOffsetTrimClipStart({
tag: "video",
sourceDuration: 12,
}),
).toBe(true);
});
it("allows front trim for plain audio clips even before media-start exists", () => {
expect(
canOffsetTrimClipStart({
tag: "audio",
}),
).toBe(true);
});
it("blocks front trim for generic motion clips", () => {
expect(
canOffsetTrimClipStart({
tag: "section",
}),
).toBe(false);
});
});
describe("hasPatchableTimelineTarget", () => {
it("returns true when the clip has a DOM id", () => {
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
@@ -201,18 +201,6 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
return Boolean(input.domId || input.selector);
}
export function canOffsetTrimClipStart(input: {
tag: string;
playbackStart?: number;
playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number;
}): boolean {
if (input.playbackStartAttr != null) return true;
if (input.playbackStart != null) return true;
const normalizedTag = input.tag.toLowerCase();
return ["video", "audio"].includes(normalizedTag);
}
export function getTimelineEditCapabilities(input: {
tag: string;
duration: number;
@@ -122,13 +122,14 @@ export function useTimelinePlayer() {
needsProbe.map(async (el) => {
const result = await probeMediaUrl(el.src!);
if (!result) return;
const current = usePlayerStore.getState().elements;
const key = el.key ?? el.id;
const idx = current.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || current[idx].sourceDuration != null) return;
const patched = current.slice();
patched[idx] = { ...current[idx], sourceDuration: result.duration };
setElements(patched);
usePlayerStore.setState((state) => {
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
const patched = state.elements.slice();
patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
return { elements: patched };
});
}),
);
}