fix(studio): a first animation in a sub-composition file runs from inside its template (#4806)

This commit is contained in:
Miguel Ángel
2026-10-01 13:40:55 -07:00
committed by GitHub
parent 78e849235a
commit 30c55694cb
4 changed files with 69 additions and 5 deletions
@@ -196,6 +196,14 @@ describe("htmlDocument helpers", () => {
);
});
it("puts markup before the outer </template>, past a nested one and a commented one", () => {
const sub =
"<template id=t><div><template><i></i></template></div></template>\n<!-- </template> -->";
expect(insertBeforeCloseTag(sub, "template", "X")).toBe(
"<template id=t><div><template><i></i></template></div>X</template>\n<!-- </template> -->",
);
});
it("ends a comment at --!>, but not on its own opening dashes", () => {
expect(insertBeforeCloseTag("<head><!-- a --!></head>", "head", "X")).toBe(
"<head><!-- a --!>X</head>",
+14 -4
View File
@@ -207,7 +207,7 @@ function inlineScriptTags(scripts: readonly string[]): string {
const RAW_TEXT_TAGS = ["script", "style", "title", "textarea"] as const;
type DocumentTag = "<head" | "</head" | "<body" | "</body";
type DocumentTag = "<head" | "</head" | "<body" | "</body" | "</template";
const COMMENT_END = /--!?>/g;
function* markupStarts(lowered: string): Generator<number> {
@@ -290,15 +290,25 @@ function findRawTextClose(lowered: string, name: string, from: number): number {
return -1;
}
function findOuterTemplateClose(html: string): number {
const lowered = lowerAscii(html);
let depth = 0;
for (const open of markupStarts(lowered)) {
if (isTagAt(lowered, open, "<template")) depth++;
else if (isTagAt(lowered, open, "</template") && --depth === 0) return open;
}
return -1;
}
function insertBeforeDocumentTag(html: string, tag: DocumentTag, markup: string): string | null {
const at = findDocumentTag(html, tag);
const at = tag === "</template" ? findOuterTemplateClose(html) : findDocumentTag(html, tag);
return at === -1 ? null : html.slice(0, at) + markup + html.slice(at);
}
/** Insert markup just before the document's own `</head>` or `</body>`; null when it has none. */
/** Insert markup before the document's own `</head>`, `</body>` or `</template>`; null if it has none. */
export function insertBeforeCloseTag(
html: string,
name: "head" | "body",
name: "head" | "body" | "template",
markup: string,
): string | null {
return insertBeforeDocumentTag(html, `</${name}`, markup);
@@ -1721,6 +1721,49 @@ const tl = gsap.timeline({ paused: true });
expect(html.indexOf('tl.set("#card"')).toBeGreaterThan(declaration);
});
async function addFirstAnimation(sub: string): Promise<string> {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeHtml(projectDir, "compositions/sub.html", sub);
const res = await app.request(
"http://localhost/projects/demo/gsap-mutations/compositions/sub.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#card",
method: "to",
position: 0,
properties: { opacity: 0 },
}),
},
);
expect(res.status).toBe(200);
return readFileSync(join(projectDir, "compositions/sub.html"), "utf-8");
}
it("a first animation in a sub-composition file lands inside its template", async () => {
const html = await addFirstAnimation(
'<template id="sub-template"><div data-composition-id="sub"><div id="card"></div></div></template>\n',
);
const close = html.indexOf("</template>");
expect(html.indexOf('window.__timelines["sub"]')).toBeGreaterThan(-1);
expect(html.lastIndexOf("<script")).toBeLessThan(close);
expect(html.slice(close).trim()).toBe("</template>");
expect(html).not.toContain("<body");
});
it("a first animation in a <body> file lands before </body>, outside its template", async () => {
const html = await addFirstAnimation(
'<body><template><div data-composition-id="sub"><div id="card"></div></div></template></body>\n',
);
expect(html.indexOf("<script")).toBeGreaterThan(html.indexOf("</template>"));
expect(html.lastIndexOf("</script>")).toBeLessThan(html.indexOf("</body>"));
});
it("consolidate-position-writes leaves exactly one position write per selector", async () => {
const projectDir = createProjectDir();
const CORRUPTED = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
+4 -1
View File
@@ -1319,7 +1319,10 @@ async function prepareGsapMutationScript(
`window.__timelines["${compId}"] = tl;`,
"</script>",
].join("\n");
html = insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
html =
insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ??
insertBeforeCloseTag(html, "template", `${bootstrap}\n`) ??
`${html}\n${bootstrap}`;
block = extractGsapScriptBlock(html);
}
if (