From 5aec522c7542d5d702f86b132147d4fa7a2aa46e Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Thu, 9 Jul 2026 21:31:03 -0700 Subject: [PATCH] fix(studio): duration readout updates live again after soft-reload edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/patchDocumentRootDuration.test.ts | 71 +++++++++++++++++++ .../src/hooks/timelineEditingHelpers.ts | 40 +++++++++++ .../studio/src/hooks/timelineElementsMove.ts | 7 +- .../studio/src/hooks/useTimelineEditing.ts | 10 ++- 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/hooks/patchDocumentRootDuration.test.ts diff --git a/packages/studio/src/hooks/patchDocumentRootDuration.test.ts b/packages/studio/src/hooks/patchDocumentRootDuration.test.ts new file mode 100644 index 0000000000..ab152914c8 --- /dev/null +++ b/packages/studio/src/hooks/patchDocumentRootDuration.test.ts @@ -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(` +
+
+
+
+ `); + 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(`
`); + 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(` +
+
+
+ `); + 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(`
`); + 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(`
`); + expect(patchDocumentRootDuration(doc, 10)).toBe(false); + }); + + it("returns false for a null document", () => { + expect(patchDocumentRootDuration(null, 10)).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index f16da17785..644bc0fe29 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -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, diff --git a/packages/studio/src/hooks/timelineElementsMove.ts b/packages/studio/src/hooks/timelineElementsMove.ts index 7bc26f77d4..f538e79d26 100644 --- a/packages/studio/src/hooks/timelineElementsMove.ts +++ b/packages/studio/src/hooks/timelineElementsMove.ts @@ -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 }); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 7d1540d2e3..18e4e810db 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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