fix(studio): keep hidden state on expanded sub-composition rows

An expanded sub-composition child row is built from a manifest clip, which
carries none of the host element's attributes, so data-hidden never reached
it. The eye on that row therefore always reported the element visible: the
first click hid it, and every click after wrote data-hidden again instead of
removing it. The element could not be shown again, not even after a reload,
because the attribute was already in the source.

The flat store element for the same child is built with its host element, so
the child row inherits hidden, timelineLocked and timelineRole from it.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:44:32 +02:00
parent adb7de5358
commit 5dad52370f
2 changed files with 53 additions and 0 deletions
@@ -221,6 +221,35 @@ describe("buildExpandedElements", () => {
expect(child.key).toBe(expectedStoreKey);
});
// Regression: a child row is built from a manifest clip, which carries none of
// the host element's attributes. Reading hidden off the manifest left every
// expanded row reporting itself visible, so the eye wrote data-hidden a second
// time instead of removing it and the element could never be shown again.
it("inherits hidden and locked state from the flat store element", () => {
const elements = [
el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
el({
id: "eyebrow",
key: "index.html#eyebrow",
domId: "eyebrow",
start: 0,
duration: 14,
hidden: true,
timelineLocked: true,
}),
];
const manifest = [
clip({ id: "s1", start: 0, duration: 14 }),
clip({ id: "eyebrow", start: 0, duration: 14 }),
];
const parentMap = new Map([["eyebrow", "s1"]]);
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
const child = out.find((e) => e.domId === "eyebrow")!;
expect(child.hidden).toBe(true);
expect(child.timelineLocked).toBe(true);
});
// Sub-comp internals (group + pills) have no data-start, so they're not in the
// manifest. They arrive as DOM children and must still expand under their host.
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
@@ -134,6 +134,27 @@ interface DisplayBounds {
track: number;
}
/**
* State that lives on the live host element, not in the clip manifest:
* `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
* built from a manifest clip with no hostEl to read, so
* createTimelineElementFromManifestClip cannot see any of it. The flat store
* element for the same child WAS built with one, so it is inherited from there.
*
* Without this the eye on an expanded child always reported the row visible, so
* clicking it wrote data-hidden again instead of removing it, and a hidden child
* could never be shown again (not even after a reload, since the attribute is in
* the source).
*/
function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
if (!flat) return {};
return {
hidden: flat.hidden,
timelineLocked: flat.timelineLocked,
timelineRole: flat.timelineRole,
};
}
// `display` bounds come from the top-level scene clip (where the expanded row is
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
// start anchors local-time edits and its compositionSrc is the file edits write to.
@@ -143,6 +164,7 @@ function buildChildElements(
display: DisplayBounds,
editBasis: { start: number; sourceFile: string | undefined },
expandedHostKey: string,
elements: readonly TimelineElement[],
): TimelineElement[] {
const result: TimelineElement[] = [];
for (const child of siblings) {
@@ -170,6 +192,7 @@ function buildChildElements(
});
result.push({
...base,
...hostElementState(elements.find((element) => element.key === key)),
key,
start: clamped.start,
duration: clamped.duration,
@@ -281,6 +304,7 @@ export function buildExpandedElements(
},
editBasis,
parentKey,
elements,
);
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);