diff --git a/.changeset/quiet-links-cross.md b/.changeset/quiet-links-cross.md new file mode 100644 index 000000000000..cdb171185ca1 --- /dev/null +++ b/.changeset/quiet-links-cross.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: add `crossorigin` to SSR stylesheet links and `Link` preload headers so browsers don't block CSS from a cross-origin `paths.assets` after client-side navigation. The asset server must now send `Access-Control-Allow-Origin` for CSS as well as JavaScript. Pages with `csr = false` could load CSS without it before. diff --git a/packages/kit/src/exports/vite/public.d.ts b/packages/kit/src/exports/vite/public.d.ts index 33e2bb2a18f8..1428b3200ce1 100644 --- a/packages/kit/src/exports/vite/public.d.ts +++ b/packages/kit/src/exports/vite/public.d.ts @@ -308,6 +308,8 @@ export interface Config extends VitePluginSvelteOptions { paths?: { /** * An absolute path that your app's files are served from. This is useful if your files are served from a storage bucket of some kind. + * + * The browser loads your app's JavaScript, CSS and fonts from this origin with CORS, so the server must respond with an `Access-Control-Allow-Origin` header that allows your app's origin. * @default "" */ assets?: '' | `http://${string}` | `https://${string}`; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0a8042eb03fa..63b691b7552c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -312,7 +312,8 @@ export async function render_response({ for (const dep of stylesheets) { const path = prefixed(dep); - const attributes = ['rel="stylesheet"']; + // match the CORS mode of the stylesheets that Vite's preload helper adds on client-side navigation + const attributes = ['rel="stylesheet"', 'crossorigin']; if (inline_styles.has(dep)) { // don't load stylesheets that are already inlined @@ -320,7 +321,7 @@ export async function render_response({ attributes.push('disabled', 'media="(max-width: 0)"'); } else { if (__SVELTEKIT_LINK_HEADER_PRELOAD__ && resolve_opts.preload({ type: 'css', path })) { - link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`); + link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; crossorigin; nopush`); } } diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index b39253268b25..bf2a5e2fd2ca 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -195,6 +195,18 @@ test.describe('Link header preload', () => { expect(header).toContain('as="font"'); }); + test('injects Link headers for stylesheets with crossorigin', async ({ request }) => { + const response = await request.get('/basepath/asset-preload'); + + const header = response.headers()['link'] ?? ''; + const stylesheets = header.split(', ').filter((entry) => entry.includes('as="style"')); + + expect(stylesheets.length).toBeGreaterThan(0); + for (const entry of stylesheets) { + expect(entry).toContain('; crossorigin;'); + } + }); + test('does not inject Link headers on prerendered pages', async ({ request }) => { const response = await request.get('/basepath/asset-preload/prerendered'); diff --git a/packages/kit/test/apps/options/test/paths.test.js b/packages/kit/test/apps/options/test/paths.test.js index 295b2b30b953..34558ab89d9b 100644 --- a/packages/kit/test/apps/options/test/paths.test.js +++ b/packages/kit/test/apps/options/test/paths.test.js @@ -151,6 +151,22 @@ test.describe('assets path', () => { expect(response.status()).toBe(200); }); + test('server-rendered stylesheet links have the crossorigin attribute', async ({ + request, + javaScriptEnabled + }) => { + test.skip(!!process.env.DEV || javaScriptEnabled, 'only applicable to the build output'); + + const response = await request.get('/path-base/base/'); + const html = await response.text(); + + const links = html.match(/]*rel="stylesheet"[^>]*>/g) ?? []; + expect(links.length).toBeGreaterThan(0); + for (const link of links) { + expect(link).toMatch(/\scrossorigin[\s>]/); + } + }); + test('client avoids generating relative URLs if paths.assets or paths.relative are truthy', async () => { test.skip(!!process.env.DEV, 'only applicable to the build output'); const nodes = readdirSync('.custom-out-dir/output/client/_wheee/nested/immutable/nodes'); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 553c98623e96..d54b6ccca586 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1894,6 +1894,8 @@ declare module '@sveltejs/kit/vite' { paths?: { /** * An absolute path that your app's files are served from. This is useful if your files are served from a storage bucket of some kind. + * + * The browser loads your app's JavaScript, CSS and fonts from this origin with CORS, so the server must respond with an `Access-Control-Allow-Origin` header that allows your app's origin. * @default "" */ assets?: '' | `http://${string}` | `https://${string}`;