diff --git a/packages/studio/src/player/components/useTimelineActiveClips.test.ts b/packages/studio/src/player/components/useTimelineActiveClips.test.ts index d84b64e9b..a7ab44e5e 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.test.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.test.ts @@ -78,6 +78,26 @@ describe("updateTimelineActiveClipClasses", () => { expect(previous).toEqual(new Set()); }); + it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => { + // A clip that moves lanes on a reorder remounts as a new element. It stays + // in the previous active set, so the plain diff would skip it and leave the + // new node without data-active. syncAll must force the attribute on. + const container = document.createElement("div"); + appendClip(container, "hero", "0", "5"); + const previous = new Set(); + updateTimelineActiveClipClasses(container, previous, 2); + expect(previous).toEqual(new Set(["hero"])); + + // Simulate a remount: replace the hero clip's DOM node (no data-active). + container.replaceChildren(); + const heroReborn = appendClip(container, "hero", "0", "5"); + expect(heroReborn.hasAttribute("data-active")).toBe(false); + + // Diff-only would skip it (still active → unchanged); syncAll re-applies. + updateTimelineActiveClipClasses(container, previous, 2, true); + expect(heroReborn.hasAttribute("data-active")).toBe(true); + }); + it("ignores clips with invalid timing data", () => { const container = document.createElement("div"); const missingId = appendClip(container, "", "0", "2"); diff --git a/packages/studio/src/player/components/useTimelineActiveClips.ts b/packages/studio/src/player/components/useTimelineActiveClips.ts index e906253fb..4971b8840 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.ts @@ -65,13 +65,23 @@ function setsMatch(left: Set, right: Set): boolean { return true; } -function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set, time: number) { +function applyActiveClipDiff( + records: ActiveClipRecord[], + previous: Set, + time: number, + // Force every record's attribute to match its active state instead of only + // touching clips whose active-state changed. Required whenever `records` were + // freshly re-queried after a render: a clip that stayed active but got a new + // DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the + // diff and render without `data-active` despite still being at the playhead. + syncAll = false, +) { const next = getActiveClipIds(records, time); const changed = !setsMatch(previous, next); for (const record of records) { const wasActive = previous.has(record.id); const isActive = next.has(record.id); - if (wasActive === isActive) continue; + if (!syncAll && wasActive === isActive) continue; record.element.toggleAttribute("data-active", isActive); } previous.clear(); @@ -83,8 +93,9 @@ export function updateTimelineActiveClipClasses( container: HTMLElement, previous: Set, time: number, + syncAll = false, ) { - applyActiveClipDiff(collectTimelineClipRecords(container), previous, time); + applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll); } export function useTimelineActiveClips({ @@ -107,7 +118,7 @@ export function useTimelineActiveClips({ } recordsRef.current = collectTimelineClipRecords(scroll); recordsByIdRef.current = indexClipRecordsById(recordsRef.current); - applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time); + applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true); }, [scrollRef], );