From 0e10435076b536a8ca03de5e2c3c633175abc462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Apr 2026 10:48:54 -0400 Subject: [PATCH] fix: isolate duplicate sub-composition instances --- .../core/src/compiler/compositionScoping.ts | 65 ++++++++++++++++--- .../core/src/compiler/htmlBundler.test.ts | 58 ++++++++++++++++- packages/core/src/compiler/htmlBundler.ts | 44 ++++++++++++- 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 4667202c3..6194723f2 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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); } diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 197c31323..1d93d219b 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -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": ` + + + +
+
+
+
+ +`, + "compositions/scene.html": ``, + }); + + 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": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 158c4c344..34e139a01 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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(); + 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(); + 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