fix: isolate duplicate sub-composition instances (#561)

This commit is contained in:
Miguel Ángel
2026-04-29 17:53:18 +02:00
committed by GitHub
parent 857b870459
commit c196b76a40
3 changed files with 155 additions and 12 deletions
@@ -18,9 +18,12 @@ function scopeSelector(selector: string, scope: string, compositionId: string):
if (!trimmed) return selector;
if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector;
const compositionIdPattern = new RegExp(
`data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1`,
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
);
if (compositionIdPattern.test(trimmed)) return selectorWithoutRootTiming;
if (compositionIdPattern.test(trimmed)) {
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
return `${leading}${scope} ${trimmed}${trailing}`;
@@ -56,10 +59,16 @@ function isInsideGlobalAtRule(rule: Rule): boolean {
return false;
}
export function scopeCssToComposition(css: string, compositionId: string): string {
export function scopeCssToComposition(
css: string,
compositionId: string,
scopeSelectorOverride?: string,
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
const scope = `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const scope =
scopeSelectorOverride ||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const root = postcss.parse(css);
root.walkRules((rule) => {
@@ -76,10 +85,14 @@ export function wrapScopedCompositionScript(
source: string,
compositionId: string,
errorLabel = "[HyperFrames] composition script error:",
scopeSelectorOverride?: string,
timelineCompositionId = compositionId,
): string {
const compositionIdLiteral = JSON.stringify(compositionId);
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
const errorLabelLiteral = JSON.stringify(errorLabel);
const escapedCompositionId = escapeRegExp(compositionId);
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = JSON.stringify(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
);
@@ -88,13 +101,14 @@ export function wrapScopedCompositionScript(
);
return `(function(){
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
};
var __hfRootSelector = __hfCompId
var __hfRootSelector = ${scopeSelectorLiteral} || (__hfCompId
? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]'
: "";
: "");
var __hfRoot = null;
var __hfRootSelectorPattern = ${rootSelectorPatternLiteral};
var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral};
@@ -143,6 +157,41 @@ export function wrapScopedCompositionScript(
},
})
: window.document;
var __hfTimelineRegistryProxy = null;
var __hfGetTimelineRegistry = function() {
window.__timelines = window.__timelines || {};
if (!__hfCompId || __hfCompId === __hfTimelineCompId || typeof Proxy !== "function") {
return window.__timelines;
}
if (!__hfTimelineRegistryProxy) {
__hfTimelineRegistryProxy = new Proxy(window.__timelines, {
get: function(target, prop, receiver) {
return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, receiver);
},
set: function(target, prop, value, receiver) {
return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, receiver);
},
});
}
return __hfTimelineRegistryProxy;
};
var __hfScopedWindow = typeof Proxy === "function"
? new Proxy(window, {
get: function(target, prop, receiver) {
if (prop === "__timelines") return __hfGetTimelineRegistry();
var value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
target.__timelines = value || {};
__hfTimelineRegistryProxy = null;
return true;
}
return Reflect.set(target, prop, value, receiver);
},
})
: window;
var __hfResolveGsapTarget = function(target) {
if (typeof target !== "string") return target;
return __hfQueryAll(target);
@@ -214,9 +263,9 @@ export function wrapScopedCompositionScript(
});
var __hfRun = function() {
try {
(function(document, gsap) {
(function(document, gsap, window) {
${source}
}).call(window, __hfScopedDocument, __hfScopedGsap);
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
+57 -1
View File
@@ -137,7 +137,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain(".logo");
// Scripts from template should be included
expect(bundled).toContain('window.__timelines["logo-reveal"]');
expect(bundled).toContain('__timelines["logo-reveal"]');
});
it("does not inline template when host already has content", async () => {
@@ -284,6 +284,62 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('tl.to(".title"');
});
it("isolates sibling instances of the same external sub-composition", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div
id="scene-a"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="5"></div>
<div
id="scene-b"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="5"
data-duration="5"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="scene"] .title { opacity: 0; }</style>
<h1 class="title">Scene</h1>
<script>
const tl = gsap.timeline({ paused: true });
tl.to('[data-composition-id="scene"] .title', { opacity: 1 });
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = tl;
</script>
</div>
</template>`,
});
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const sceneA = document.querySelector("#scene-a");
const sceneB = document.querySelector("#scene-b");
const sceneAId = sceneA?.getAttribute("data-composition-id") ?? "";
const sceneBId = sceneB?.getAttribute("data-composition-id") ?? "";
expect(sceneAId).not.toBe("scene");
expect(sceneBId).not.toBe("scene");
expect(sceneAId).not.toBe(sceneBId);
expect(sceneA?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(sceneB?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(bundled).toContain(`[data-composition-id="${sceneAId}"] .title`);
expect(bundled).toContain(`[data-composition-id="${sceneBId}"] .title`);
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf1"');
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf2"');
expect(bundled).not.toContain('[data-composition-id="scene"] .title { opacity: 0; }');
});
it("rewrites CSS url(...) asset paths from sub-compositions when styles are hoisted", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
+41 -3
View File
@@ -193,6 +193,14 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
);
}
function cssAttributeSelector(attr: string, value: string): string {
return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
}
function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
function enforceCompositionPixelSizing(document: Document): void {
const compositionEls = [
...document.querySelectorAll("[data-composition-id][data-width][data-height]"),
@@ -422,7 +430,15 @@ export async function bundleToSingleHtml(
const compStyleChunks: string[] = [];
const compScriptChunks: string[] = [];
const compExternalScriptSrcs: string[] = [];
for (const hostEl of [...document.querySelectorAll("[data-composition-src]")]) {
const subCompositionHosts = [...document.querySelectorAll("[data-composition-src]")];
const hostCountsByCompositionId = new Map<string, number>();
for (const hostEl of subCompositionHosts) {
const compId = (hostEl.getAttribute("data-composition-id") || "").trim();
if (!compId) continue;
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
}
const hostInstanceByCompositionId = new Map<string, number>();
for (const hostEl of subCompositionHosts) {
const src = hostEl.getAttribute("data-composition-src");
if (!src || !isRelativeUrl(src)) continue;
const compPath = safePath(projectDir, src);
@@ -442,6 +458,22 @@ export async function bundleToSingleHtml(
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const scopeCompId = compId || inferredCompId;
const duplicateInstance = scopeCompId && (hostCountsByCompositionId.get(scopeCompId) || 0) > 1;
const instanceIndex = duplicateInstance
? (hostInstanceByCompositionId.get(scopeCompId) || 0) + 1
: 0;
if (duplicateInstance) hostInstanceByCompositionId.set(scopeCompId, instanceIndex);
const runtimeCompId =
duplicateInstance && scopeCompId
? uniqueCompositionId(scopeCompId, instanceIndex)
: scopeCompId;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
: "";
if (duplicateInstance && runtimeCompId) {
hostEl.setAttribute("data-hf-original-composition-id", scopeCompId);
hostEl.setAttribute("data-composition-id", runtimeCompId);
}
// When a sub-composition is a full HTML document (no <template>), styles
// and scripts in <head> are not part of contentDoc (which only has body
@@ -450,7 +482,9 @@ export async function bundleToSingleHtml(
if (!contentRoot && compDoc.head) {
for (const s of [...compDoc.head.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
);
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
@@ -462,7 +496,9 @@ export async function bundleToSingleHtml(
for (const s of [...contentDoc.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
);
s.remove();
}
for (const s of [...contentDoc.querySelectorAll("script")]) {
@@ -480,6 +516,8 @@ export async function bundleToSingleHtml(
s.textContent || "",
scopeCompId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || scopeCompId,
)
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);