fix(studio): keep a remounted clip marked active when it stays at the playhead

The structural refresh now force-syncs data-active on freshly queried clip
nodes instead of diffing. A clip that changes lanes on a reorder remounts as
a new DOM node; if it stayed under the playhead the diff skipped it and it
rendered as inactive.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:29 -04:00
parent eb6a311d82
commit ffd513130b
2 changed files with 35 additions and 4 deletions
@@ -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<string>();
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");
@@ -65,13 +65,23 @@ function setsMatch(left: Set<string>, right: Set<string>): boolean {
return true;
}
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
function applyActiveClipDiff(
records: ActiveClipRecord[],
previous: Set<string>,
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<string>,
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],
);