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 = ``; + 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;