fix(studio): a first animation in a full-document sub-composition plays (#5008)

* fix(studio): a first animation in a full-document sub-composition plays

* fix(studio): keep the comment share of the two touched files at their base

* fix(studio): a first animation also plays when <html> carries the composition id

* fix(studio): a root whose id sits on <html> keeps its first animation in <body>

* fix(studio): a composition id on the template tag keeps the first animation inside it
This commit is contained in:
Miguel Ángel
2026-10-04 14:05:18 +00:00
committed by GitHub
parent 69a2169bde
commit 399f49104e
4 changed files with 115 additions and 6 deletions
@@ -5,6 +5,7 @@ import {
injectScriptsIntoHtml,
injectTagsAtHeadStart,
insertBeforeCloseTag,
hasCompositionOutsideTemplates,
isFullHtmlDocument,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
@@ -272,3 +273,47 @@ describe("injectTagsAtHeadStart on long adversarial input", () => {
expect(performance.now() - started).toBeLessThan(1500);
});
});
describe("hasCompositionOutsideTemplates", () => {
it.each([
["a root in <body>", '<body><div data-composition-id="main"></div></body>', true],
[
"a root in <body> beside an inline template",
'<body><div data-composition-id="main"></div><template><div data-composition-id="x"></div></template></body>',
true,
],
[
"a full document whose composition is in its template",
'<html><body><template id="s-template"><div data-composition-id="s"></div></template></body></html>',
false,
],
["a bare template", '<template><div data-composition-id="s"></div></template>', false],
[
"an id on <html> and on the <template> tag itself",
'<html data-composition-id="s"><body><template data-composition-id="s"><div></div></template></body></html>',
false,
],
[
"a root id on <html> beside a template with no composition",
'<html data-composition-id="main"><body><template><p>clone me</p></template></body></html>',
true,
],
[
"a full document whose <html> carries the id",
'<html data-composition-id="s"><body data-composition-id="s"><template><div data-composition-id="s"></div></template></body></html>',
false,
],
[
"the attribute only in a comment or text",
'<body><!-- <div data-composition-id="a"> --><p>data-composition-id="b"</p></body>',
false,
],
[
"a nested template",
'<template><template></template><div data-composition-id="s"></div></template>',
false,
],
])("%s", (_name, html, expected) => {
expect(hasCompositionOutsideTemplates(html)).toBe(expected);
});
});
@@ -300,6 +300,35 @@ function findOuterTemplateClose(html: string): number {
return -1;
}
const COMPOSITION_ID_ATTR = /\sdata-composition-id\s*=/;
const isDocumentWrapper = (lowered: string, open: number): boolean =>
isTagAt(lowered, open, "<html") || isTagAt(lowered, open, "<body");
function carriesCompositionId(lowered: string, open: number): boolean {
const end = findTagEnd(lowered, open + 1);
return end !== -1 && COMPOSITION_ID_ATTR.test(lowered.slice(open, end));
}
export function hasCompositionOutsideTemplates(html: string): boolean {
const lowered = lowerAscii(html);
let depth = 0;
let idOnWrapper = false;
let idInTemplate = false;
for (const open of markupStarts(lowered)) {
if (isTagAt(lowered, open, "<template")) {
depth++;
if (carriesCompositionId(lowered, open)) idInTemplate = true;
} else if (isTagAt(lowered, open, "</template")) depth = Math.max(0, depth - 1);
else if (/[a-z]/.test(lowered.charAt(open + 1)) && carriesCompositionId(lowered, open)) {
if (depth > 0) idInTemplate = true;
else if (isDocumentWrapper(lowered, open)) idOnWrapper = true;
else return true;
}
}
return idOnWrapper && !idInTemplate;
}
function insertBeforeDocumentTag(html: string, tag: DocumentTag, markup: string): string | null {
const at = tag === "</template" ? findOuterTemplateClose(html) : findDocumentTag(html, tag);
return at === -1 ? null : html.slice(0, at) + markup + html.slice(at);
@@ -1949,11 +1949,40 @@ const tl = gsap.timeline({ paused: true });
expect(html).not.toContain("<body");
});
it("a first animation in a <body> file lands before </body>, outside its template", async () => {
// The registry-block shape: the loader mounts the template's content, so a script outside it never runs.
it("a first animation in a full-document sub-composition lands inside its template", async () => {
const html = await addFirstAnimation(
'<body><template><div data-composition-id="sub"><div id="card"></div></div></template></body>\n',
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><template id="sub-template"><div data-composition-id="sub"><div id="card"></div></div></template></body></html>\n',
);
expect(html.indexOf("<script")).toBeGreaterThan(html.indexOf("</template>"));
const close = html.indexOf("</template>");
expect(html.indexOf('window.__timelines["sub"]')).toBeGreaterThan(-1);
expect(html.lastIndexOf("</script>")).toBeLessThan(close);
expect(html.slice(close)).toBe("</template></body></html>\n");
});
it("a first animation lands inside the template when <html> carries the composition id", async () => {
const html = await addFirstAnimation(
'<!DOCTYPE html><html data-composition-id="sub"><body><template><div data-composition-id="sub"><div id="card"></div></div></template></body></html>\n',
);
const close = html.indexOf("</template>");
expect(html.lastIndexOf("</script>")).toBeLessThan(close);
expect(html.slice(close)).toBe("</template></body></html>\n");
});
it("a first animation lands inside the template when the <template> tag carries the composition id", async () => {
const html = await addFirstAnimation(
'<!DOCTYPE html><html data-composition-id="sub"><body><template data-composition-id="sub"><div id="card"></div></template></body></html>\n',
);
const close = html.indexOf("</template>");
expect(html.lastIndexOf("</script>")).toBeLessThan(close);
expect(html.slice(close)).toBe("</template></body></html>\n");
});
it("a first animation in a file whose composition is in <body> lands before </body>", async () => {
const html = await addFirstAnimation(
'<body><div data-composition-id="main"><div id="card"></div></div><template id="other-template"><div data-composition-id="other"></div></template></body>\n',
);
expect(html.indexOf('window.__timelines["main"]')).toBeGreaterThan(html.indexOf("</template>"));
expect(html.lastIndexOf("</script>")).toBeLessThan(html.indexOf("</body>"));
});
+9 -3
View File
@@ -105,7 +105,10 @@ import {
} from "../helpers/compositionInsertion.js";
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
import {
hasCompositionOutsideTemplates,
insertBeforeCloseTag,
} from "@hyperframes/core/compiler/html-document";
// ── Server cutover flag ─────────────────────────────────────────────────────
@@ -1457,9 +1460,12 @@ async function prepareGsapMutationScript(
`window.__timelines["${compId}"] = tl;`,
"</script>",
].join("\n");
const [first, second] = hasCompositionOutsideTemplates(html)
? (["body", "template"] as const)
: (["template", "body"] as const);
html =
insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ??
insertBeforeCloseTag(html, "template", `${bootstrap}\n`) ??
insertBeforeCloseTag(html, first, `${bootstrap}\n`) ??
insertBeforeCloseTag(html, second, `${bootstrap}\n`) ??
`${html}\n${bootstrap}`;
block = extractGsapScriptBlock(html);
}