Skip to content

Commit fe51808

Browse files
committed
fix(core): resolve nested compositions from the project root
1 parent a0e2448 commit fe51808

4 files changed

Lines changed: 56 additions & 9 deletions

File tree

‎packages/core/src/compiler/htmlBundler.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resetUnknownEnumWarnings } from "../runtime/getVariables";
1010
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
1111
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
1212
import { ensureHfIds } from "../parsers/hfIds";
13+
import { nestedCompositionPathFixture } from "./nestedCompositionPath.testFixture";
1314
import { AFTER_FONTS_SCRIPT_TYPE, AFTER_FONTS_SCRIPTS } from "./scriptRuns";
1415

1516
function makeTempProject(files: Record<string, string>): string {
@@ -81,6 +82,16 @@ function makeSymlinkProject(
8182
}
8283

8384
describe("bundleToSingleHtml", () => {
85+
it("matches runtime root-relative child paths from a nested composition directory", async () => {
86+
const dir = makeTempProject(nestedCompositionPathFixture);
87+
try {
88+
const { document } = parseHTML(await bundleToSingleHtml(dir));
89+
expect(document.querySelector("[data-proof]")?.textContent).toBe("Project-root card");
90+
} finally {
91+
rmSync(dir, { recursive: true, force: true });
92+
}
93+
});
94+
8495
it.each(["file", "template"])(
8596
"keeps %s mount ownership in compiled output only",
8697
async (kind) => {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const nestedCompositionPathFixture: Record<string, string> = {
2+
"index.html":
3+
'<html><body><div data-composition-id="main" data-width="320" data-height="180"><div data-composition-id="scene" data-composition-src="compositions/scene.html"></div></div></body></html>',
4+
"compositions/scene.html":
5+
'<html><body><div data-composition-id="scene"><div data-composition-id="card" data-composition-src="compositions/cards/card.html"></div></div></body></html>',
6+
"compositions/cards/card.html":
7+
'<html><body><div data-composition-id="card"><p data-proof>Project-root card</p></div></body></html>',
8+
};

‎packages/core/src/runtime/compositionLoader.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
2+
import { nestedCompositionPathFixture } from "../compiler/nestedCompositionPath.testFixture";
23
import { loadCompositions } from "./compositionLoader";
34
import { applyVariableBindings } from "./applyVariableBindings";
45

@@ -48,10 +49,13 @@ beforeAll(() => {
4849

4950
describe("loadCompositions external hosts", () => {
5051
it("loads external children in one pass without fetching their parent again", async () => {
52+
const base = document.createElement("base");
53+
base.href = "https://example.com/";
54+
document.head.appendChild(base);
5155
const host = appendExternalHost("https://example.com/scenes/outer.html", "outer");
5256
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
5357
const html = String(input).endsWith("outer.html")
54-
? '<html><body><div data-composition-id="outer"><div data-composition-id="inner" data-composition-src="inner.html"></div></div></body></html>'
58+
? '<html><body><div data-composition-id="outer"><div data-composition-id="inner" data-composition-src="scenes/inner.html"></div></div></body></html>'
5559
: '<html><body><div data-composition-id="inner"><p>Inner content</p></div></body></html>';
5660
return new Response(html, { status: 200 });
5761
});
@@ -63,6 +67,27 @@ describe("loadCompositions external hosts", () => {
6367
]);
6468
});
6569

70+
it("matches bundled root-relative child paths from a nested composition directory", async () => {
71+
const files = nestedCompositionPathFixture;
72+
const base = document.createElement("base");
73+
base.href = "https://example.com/project/";
74+
document.head.appendChild(base);
75+
document.body.innerHTML = files["index.html"]!;
76+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
77+
const relative = new URL(String(input)).pathname.slice("/project/".length);
78+
const content = files[relative];
79+
return new Response(content ?? "Not found", { status: content === undefined ? 404 : 200 });
80+
});
81+
const onDiagnostic = vi.fn();
82+
await loadFixture({ onDiagnostic });
83+
expect(document.querySelector("[data-proof]")?.textContent).toBe("Project-root card");
84+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
85+
"https://example.com/project/compositions/scene.html",
86+
"https://example.com/project/compositions/cards/card.html",
87+
]);
88+
expect(onDiagnostic).not.toHaveBeenCalled();
89+
});
90+
6691
it("loads external children introduced by a template mount", async () => {
6792
document.body.innerHTML = `<template id="outer-template"><div data-composition-id="outer"><div data-composition-id="inner" data-composition-src="https://example.com/inner.html"></div></div></template><div data-composition-id="outer"></div>`;
6893
const fetchMock = vi
@@ -98,6 +123,9 @@ describe("loadCompositions external hosts", () => {
98123
});
99124

100125
it("refuses a circular nested external reference without fetching it again", async () => {
126+
const base = document.createElement("base");
127+
base.href = "https://example.com/";
128+
document.head.appendChild(base);
101129
const host = appendExternalHost("https://example.com/loop.html", "loop");
102130
const fetchMock = vi
103131
.spyOn(globalThis, "fetch")
@@ -159,7 +187,7 @@ describe("loadCompositions external hosts", () => {
159187

160188
afterEach(() => {
161189
document.body.innerHTML = "";
162-
document.head.querySelectorAll("style, link").forEach((node) => node.remove());
190+
document.head.querySelectorAll("style, link, base").forEach((node) => node.remove());
163191
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).gsap;
164192
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).__selectedTitle;
165193
delete (window as Window & { __hyperframes?: unknown }).__hyperframes;

‎packages/core/src/runtime/compositionLoader.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,9 @@ function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
242242
return prepared;
243243
}
244244

245-
function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): string {
246-
const trimmedSrc = scriptSrc.trim();
247-
if (!trimmedSrc) return scriptSrc;
245+
function resolvePayloadSourceUrl(source: string, compositionUrl: URL | null): string {
246+
const trimmedSrc = source.trim();
247+
if (!trimmedSrc) return source;
248248
try {
249249
if (
250250
BARE_RELATIVE_PATH_RE.test(trimmedSrc) &&
@@ -259,7 +259,7 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
259259
}
260260
return new URL(trimmedSrc, document.baseURI).toString();
261261
} catch {
262-
return scriptSrc;
262+
return source;
263263
}
264264
}
265265

@@ -533,7 +533,7 @@ async function mountCompositionContent(params: {
533533
const type = script.getAttribute("type")?.trim() ?? "";
534534
const src = script.getAttribute("src")?.trim() ?? "";
535535
if (src) {
536-
const resolvedSrc = resolveScriptSourceUrl(src, params.compositionUrl);
536+
const resolvedSrc = resolvePayloadSourceUrl(src, params.compositionUrl);
537537
// A sub-comp that <script src>s itself would re-enter the mount; skip it.
538538
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
539539
return null;
@@ -616,7 +616,7 @@ async function mountCompositionContent(params: {
616616
injectedScript.src = scriptPayload.src;
617617
} else if (scriptPayload.type.toLowerCase() === "importmap") {
618618
const map = parseImportMap(scriptPayload.content, (url) =>
619-
resolveScriptSourceUrl(url, params.compositionUrl),
619+
resolvePayloadSourceUrl(url, params.compositionUrl),
620620
);
621621
injectedScript.textContent = map ? JSON.stringify(map) : scriptPayload.content;
622622
} else if (scriptPayload.type.toLowerCase() === "module") {
@@ -739,7 +739,7 @@ async function mountExternalCompositions(
739739
const ancestry = (parent && attemptedPaths.get(parent)) || [];
740740
let compositionUrl: URL | null = null;
741741
try {
742-
compositionUrl = new URL(src, ancestry.at(-1) ?? document.baseURI);
742+
compositionUrl = new URL(resolvePayloadSourceUrl(src, null));
743743
} catch {
744744
compositionUrl = null;
745745
}

0 commit comments

Comments
 (0)