fix(studio): duration readout updates live again after soft-reload edits

Post-rebase regression: the optimistic setDuration on move/resize was
being immediately reverted — the soft-reload path's __hfForceTimelineRebind
makes the runtime re-read the iframe's root data-duration and re-post it,
overwriting the store with the stale pre-edit value. Fix: the optimistic
sync now also patches the iframe document's root data-duration (grow AND
shrink) before the rebind, so the runtime re-posts the CORRECT value and
the readout stays live. Covered by patchDocumentRootDuration tests.
This commit is contained in:
ukimsanov
2026-07-10 10:23:58 -07:00
parent b009b5aef1
commit 5aec522c75
4 changed files with 126 additions and 2 deletions
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { patchDocumentRootDuration } from "./timelineEditingHelpers";
// The regression these guard against: timing edits now soft-reload instead of
// full-reloading the iframe, so the runtime recomputes the composition length
// from the ROOT's `data-duration` and posts it back — overwriting the studio's
// optimistic duration readout. Patching the live root's `data-duration` in the
// same tick keeps the runtime's post-soft-reload report in agreement, so the
// readout stays live on grow AND shrink instead of snapping back.
function parse(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}
describe("patchDocumentRootDuration", () => {
it("writes the content end onto the TOP-LEVEL composition root (grow)", () => {
const doc = parse(`
<div data-composition-id="root" data-duration="15.18">
<div data-composition-id="scene" data-start="0" data-duration="8"></div>
<div data-start="12" data-duration="8"></div>
</div>
`);
expect(patchDocumentRootDuration(doc, 20)).toBe(true);
const root = doc.querySelector('[data-composition-id="root"]');
expect(root?.getAttribute("data-duration")).toBe("20");
// Nested sub-composition is untouched — only the outermost root is the length.
expect(doc.querySelector('[data-composition-id="scene"]')?.getAttribute("data-duration")).toBe(
"8",
);
});
it("shrinks the root duration too (not grow-only)", () => {
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
expect(patchDocumentRootDuration(doc, 6.5)).toBe(true);
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("6.5");
});
it("picks the composition with no ancestor composition as the root", () => {
// Root appears AFTER a nested one in document order — selection must be by
// ancestry, not first-match, matching the runtime's own root resolver.
const doc = parse(`
<section data-composition-id="root" data-duration="10">
<div data-composition-id="inner" data-duration="4"></div>
</section>
`);
patchDocumentRootDuration(doc, 12);
expect(doc.querySelector('[data-composition-id="root"]')?.getAttribute("data-duration")).toBe(
"12",
);
expect(doc.querySelector('[data-composition-id="inner"]')?.getAttribute("data-duration")).toBe(
"4",
);
});
it("no-ops on a non-positive or non-finite content end (never collapses to 0)", () => {
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
expect(patchDocumentRootDuration(doc, 0)).toBe(false);
expect(patchDocumentRootDuration(doc, -3)).toBe(false);
expect(patchDocumentRootDuration(doc, Number.NaN)).toBe(false);
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("15.18");
});
it("no-ops when there is no composition root and reports false", () => {
const doc = parse(`<div data-start="0" data-duration="5"></div>`);
expect(patchDocumentRootDuration(doc, 10)).toBe(false);
});
it("returns false for a null document", () => {
expect(patchDocumentRootDuration(null, 10)).toBe(false);
});
});
@@ -73,6 +73,46 @@ export function patchIframeDomTiming(
}
}
/**
* Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]`
* with no ancestor composition, matching the runtime's own root resolution) and
* write `contentEnd` into its `data-duration`. Returns whether a write happened.
*
* A timing edit optimistically patches the moved clip's `data-start`/`-duration`
* in the live iframe, but NOT the root's `data-duration`. Timing edits now take
* the soft-reload path (no full iframe reload), which re-runs the GSAP script and
* lets the runtime recompute the composition length — it reads the root's
* `data-duration` as the authored floor (core/runtime/init.ts) and posts it back,
* so the studio store's duration is set from the STALE root and the readout
* reverts to the pre-edit length. Patching the root here keeps the runtime's
* post-soft-reload duration report in agreement with the optimistic readout, so
* the number stays live (grow AND shrink) instead of snapping back.
*/
export function patchDocumentRootDuration(
doc: Document | null | undefined,
contentEnd: number,
): boolean {
if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
const root =
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
if (!root) return false;
root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
return true;
}
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */
export function patchIframeRootDuration(
iframe: HTMLIFrameElement | null,
contentEnd: number,
): void {
try {
patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
// fallow-ignore-next-line complexity
export function resolveResizePlaybackStart(
original: string,
@@ -11,6 +11,7 @@ import {
buildPatchTarget,
formatTimelineAttributeNumber,
patchIframeDomTiming,
patchIframeRootDuration,
readFileContent,
shiftGsapPositionsBatch,
syncTimingEditPreview,
@@ -136,9 +137,13 @@ export async function persistTimelineElementsMove(
// Optimistically reflect the new composition length in the player store so
// the duration readout + seek bar update immediately (they bind to
// store.duration; without this the number stays frozen until a manual
// refresh). Only for the composition currently displayed.
// refresh). Only for the composition currently displayed. Also patch the live
// root's `data-duration`: a batched move takes the soft-reload path (no full
// iframe reload), so the runtime recomputes the length from the root's
// declared duration and posts it back — a stale root would revert this set.
if (contentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(contentEnd);
patchIframeRootDuration(previewIframe, contentEnd);
}
groupResults.push({ targetPath, original, patched, groupEdits });
@@ -33,6 +33,7 @@ import { generateId } from "../utils/generateId";
import {
buildPatchTarget,
patchIframeDomTiming,
patchIframeRootDuration,
resolveResizePlaybackStart,
persistTimelineEdit,
readFileContent,
@@ -130,7 +131,14 @@ export function useTimelineEditing({
// data-duration) so it's immune to the runtime's truncated live durations.
const syncReadoutDurationFromPreview = useCallback(() => {
const end = furthestClipEndFromDocument(previewIframeRef.current?.contentDocument ?? null);
if (end > 0) usePlayerStore.getState().setDuration(end);
if (end > 0) {
usePlayerStore.getState().setDuration(end);
// Also write the content end into the live root's `data-duration`. Timing
// edits now take the soft-reload path (no full iframe reload), which lets
// the runtime recompute the length from the root's declared duration and
// post it back — reading the STALE root would revert this optimistic set.
patchIframeRootDuration(previewIframeRef.current, end);
}
}, [previewIframeRef]);
// fallow-ignore-next-line complexity