diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts
index 3f066cce840..8881392c596 100644
--- a/packages/core/src/runtime/compositionLoader.test.ts
+++ b/packages/core/src/runtime/compositionLoader.test.ts
@@ -262,6 +262,35 @@ describe("loadCompositions external hosts", () => {
).toBeNull();
});
+ // `assets/theme.css` in a sub-composition loaded from compositions/assets/ in the preview and 404'd, while the render
+ // resolved it from the project root (rewriteAssetPath: a plain path is a sibling only if one exists).
+ it.each([
+ ["no sibling file: the project root", false, "assets/theme.css"],
+ ["a sibling file: beside the composition", true, "compositions/assets/theme.css"],
+ ])(
+ "a plain stylesheet path resolves as the render does, %s",
+ async (_case, sibling, expected) => {
+ appendExternalHost("compositions/scene.html", "scene");
+ const compositionHtml = `
+
Styled
+ `;
+ const siblingUrl = new URL("compositions/assets/theme.css", document.baseURI).href;
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
+ const url = new URL(String(input instanceof Request ? input.url : input), document.baseURI)
+ .href;
+ if (url === siblingUrl) return new Response("", { status: sibling ? 200 : 404 });
+ return new Response(compositionHtml, { status: 200 });
+ });
+
+ await loadFixture();
+
+ const hrefs = [
+ ...document.head.querySelectorAll('link[rel="stylesheet"]'),
+ ].map((link) => link.href);
+ expect(hrefs).toEqual([new URL(expected, document.baseURI).href]);
+ },
+ );
+
it("does not inject stylesheet href variants that resolve to the composition document", async () => {
appendExternalHost("https://example.com/compositions/scene.html", "scene");
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index 0f7814e0af0..0c2e08aaadc 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -182,6 +182,24 @@ function rewriteSubCompositionAssetPaths(root: ParentNode, compositionUrl: URL |
}
}
+/**
+ * A hoisted ``'s URL, by the compiler's rule (`rewriteAssetPath` with its `assetExists` probe): a plain relative
+ * href is the file beside the composition when one is there, else the project root's (`assets/theme.css`). `../`
+ * hrefs are already absolute from `rewriteSubCompositionAssetPaths`.
+ */
+async function hoistedLinkHref(rawHref: string, compositionUrl: URL | null): Promise {
+ if (!compositionUrl) return rawHref;
+ if (isNonRelativeRuntimeUrl(rawHref)) return new URL(rawHref, compositionUrl).href;
+ const sibling = new URL(rawHref, compositionUrl).href;
+ const root = new URL(rawHref, document.baseURI).href;
+ if (sibling === root) return root;
+ try {
+ return (await fetch(sibling, { method: "HEAD" })).ok ? sibling : root;
+ } catch {
+ return root;
+ }
+}
+
function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
@@ -487,7 +505,7 @@ async function mountCompositionContent(params: {
for (const link of plan.linkSources) {
const rawHref = (link.getAttribute("href") || "").trim();
if (!rawHref) continue;
- const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref;
+ const href = await hoistedLinkHref(rawHref, params.compositionUrl);
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
const clonedLink = link.cloneNode(true);
if (!isLinkElement(clonedLink)) continue;