From 12546985f588df8cf984d241dd27e3417edb3320 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 28 Jul 2026 16:46:17 +0200 Subject: [PATCH] fix(studio): expand sub-comp children against their resolved parent host buildExpandedElements synthesized DOM-only sub-composition children against the top-level element rather than the parentHost it resolves immediately after. Under two-level nesting every child row therefore inherited the top-level window instead of its own host's, so the rows drew at the wrong offset and duration. --- .../hooks/useExpandedTimelineElements.test.ts | 39 +++++++++++++++++++ .../hooks/useExpandedTimelineElements.ts | 19 ++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 12adcd2cd..98b37ad81 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -243,6 +243,45 @@ describe("buildExpandedElements", () => { // The host row is replaced by its children. expect(out.some((e) => e.domId === "scene-host")).toBe(false); }); + + // Regression: DOM-only children were synthesized against the TOP-LEVEL element + // instead of the sub-comp host they actually live in, so every child row read + // the whole top-level window rather than its host's. + it("spans DOM-only children over their nested host's window, not the top-level one", () => { + const elements = [ + el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }), + ]; + const manifest = [ + clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }), + clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }), + ]; + const parentMap = new Map([ + ["sub-host", "scene-host"], + ["pill-1", "sub-host"], + ]); + const domClipChildren = [ + { + id: "pill-1", + parentId: "sub-host", + hostId: "sub-host", + label: "pill-1", + stackingContextId: "css:0.0", + }, + ]; + + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "scene-host", + "sub-host", + domClipChildren, + ); + const pill = out.find((e) => e.domId === "pill-1")!; + expect(pill.start).toBe(5); + expect(pill.duration).toBe(6); + expect(pill.sourceFile).toBe("sub.html"); + }); }); describe("resolveTimelineExpansionRawId", () => { diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 7ce6c5d66..17309385b 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -209,7 +209,13 @@ function buildChildElements( function domSiblingClips( domClipChildren: DomClipChild[], siblingParentId: string, - host: TimelineElement, + host: { + id: string | null; + start: number; + duration: number; + track: number; + compositionSrc?: string | null; + }, ): ClipManifestClip[] { return domClipChildren .filter((c) => c.parentId === siblingParentId) @@ -243,20 +249,23 @@ export function buildExpandedElements( const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId); if (!topLevelElement) return filterToTopLevel(elements, parentMap); + // The sub-comp host the children actually live in: top-level host for 1-level + // nesting, a nested host for deeper nesting. Its start/file anchor edits. + const parentHost = manifest.find((c) => c.id === siblingParentId); + // Prefer real manifest children; fall back to DOM-only sub-comp children // (groups/pills) that have no data-start and thus never enter the manifest. + // Those are synthesized against the host they actually live in, not the + // top-level element, or every child row reads the whole top-level window. const siblings = (() => { const fromManifest = manifest.filter( (c) => c.id != null && parentMap.get(c.id) === siblingParentId, ); if (fromManifest.length > 0) return fromManifest; - return domSiblingClips(domClipChildren, siblingParentId, topLevelElement); + return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement); })(); if (siblings.length === 0) return filterToTopLevel(elements, parentMap); - // The sub-comp host the children actually live in: top-level host for 1-level - // nesting, a nested host for deeper nesting. Its start/file anchor edits. - const parentHost = manifest.find((c) => c.id === siblingParentId); const editBasis = { start: parentHost?.start ?? topLevelElement.start, sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,