diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index fed63cae3..ab054e557 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -165,6 +165,52 @@ describe("initSandboxRuntimeModular", () => {
expect(child.style.visibility).toBe("hidden");
});
+ it("keeps external composition hosts visible through their authored duration", async () => {
+ const root = document.createElement("div");
+ root.setAttribute("data-composition-id", "main");
+ root.setAttribute("data-root", "true");
+ root.setAttribute("data-start", "0");
+ root.setAttribute("data-width", "1920");
+ root.setAttribute("data-height", "1080");
+ document.body.appendChild(root);
+
+ const child = document.createElement("div");
+ child.setAttribute("data-composition-id", "sub");
+ child.setAttribute("data-composition-src", "compositions/sub.html");
+ child.setAttribute("data-start", "0");
+ child.setAttribute("data-duration", "3");
+ root.appendChild(child);
+
+ const template = document.createElement("template");
+ template.id = "sub-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ (window as Window & { __timelines?: Record }).__timelines = {
+ main: createMockTimeline(3),
+ sub: createMockTimeline(1),
+ };
+
+ initSandboxRuntimeModular();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+
+ const player = (
+ window as Window & {
+ __player?: { renderSeek: (timeSeconds: number) => void };
+ }
+ ).__player;
+ expect(player).toBeDefined();
+ expect(child.querySelector("#hold-marker")?.textContent).toBe("HOLD ME");
+
+ player?.renderSeek(2);
+
+ expect(child.style.visibility).toBe("visible");
+ });
+
it("pads the root timeline to the authored composition schedule before seeking visibility", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 7ecc5a594..04b9ca6ed 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -255,9 +255,9 @@ export function initSandboxRuntimeModular(): void {
if (authoredEnd != null && !node.hasAttribute(AUTHORED_END_ATTR)) {
node.setAttribute(AUTHORED_END_ATTR, authoredEnd);
}
- // Non-root compositions derive visible duration from timeline.
- // Strip both data-duration AND data-end so the visibility system
- // falls back to the GSAP timeline duration (parity with preview).
+ // Strip public timing attrs on non-root compositions after preserving
+ // authored values privately. Runtime timing can still distinguish
+ // authored host windows from live child timeline durations.
node.removeAttribute("data-duration");
node.removeAttribute("data-end");
}
@@ -1335,9 +1335,18 @@ export function initSandboxRuntimeModular(): void {
}
}
- // Composition hosts must respect both the authored parent clip window
- // and the child composition's own live timeline duration.
- if (duration != null && duration > 0 && liveDuration != null) {
+ const usesExternalCompositionSlot = rawNode.hasAttribute("data-composition-src");
+
+ // Generic child compositions retain legacy behavior and respect both
+ // the authored parent clip window and the live child timeline duration.
+ // External composition hosts render into an authored slot, so a shorter
+ // child timeline should hold its final state through that slot.
+ if (
+ duration != null &&
+ duration > 0 &&
+ liveDuration != null &&
+ !usesExternalCompositionSlot
+ ) {
duration = Math.min(duration, liveDuration);
} else if ((duration == null || duration <= 0) && liveDuration != null) {
duration = liveDuration;