mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: isolate duplicate sub-composition instances (#561)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); } })();`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user