From 76b2a0b3a8b679d3fa8adda8706b1baff69f7907 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 17 Mar 2026 13:11:10 +0100 Subject: [PATCH 1/3] test(fonts): conflicting css variable (#15955) --- .../test/units/assets/fonts/core.test.js | 177 +++++++++++------- 1 file changed, 109 insertions(+), 68 deletions(-) diff --git a/packages/astro/test/units/assets/fonts/core.test.js b/packages/astro/test/units/assets/fonts/core.test.js index 2ce75a1aceac..4b45fb2ab8e0 100644 --- a/packages/astro/test/units/assets/fonts/core.test.js +++ b/packages/astro/test/units/assets/fonts/core.test.js @@ -461,87 +461,128 @@ describe('fonts core', () => { }); }); - it('getOrCreateFontFamilyAssets()', () => { - /** @type {Array} */ - const families = [ - { - name: 'Foo', - uniqueName: 'Foo-xxx', - cssVariable: '--foo', - provider: { - name: 'foo', - resolveFont: () => undefined, + describe('getOrCreateFontFamilyAssets()', () => { + it('reuses the same object as needed', () => { + /** @type {Array} */ + const families = [ + { + name: 'Foo', + uniqueName: 'Foo-xxx', + cssVariable: '--foo', + provider: { + name: 'foo', + resolveFont: () => undefined, + }, + weights: ['400'], }, - weights: ['400'], - }, - { - name: 'Foo', - uniqueName: 'Foo-yyy', - cssVariable: '--foo', - provider: { - name: 'foo', - resolveFont: () => undefined, + { + name: 'Foo', + uniqueName: 'Foo-yyy', + cssVariable: '--foo', + provider: { + name: 'foo', + resolveFont: () => undefined, + }, + styles: ['italic'], }, - styles: ['italic'], - }, - { - name: 'Bar', - uniqueName: 'Bar-xxx', - cssVariable: '--bar', - provider: { - name: 'bar', - resolveFont: () => undefined, + { + name: 'Bar', + uniqueName: 'Bar-xxx', + cssVariable: '--bar', + provider: { + name: 'bar', + resolveFont: () => undefined, + }, }, - }, - ]; + ]; - /** @type {import('../../../../dist/assets/fonts/types.js').FontFamilyAssetsByUniqueKey} */ - const fontFamilyAssetsByUniqueKey = new Map(); - const logger = new SpyLogger(); + /** @type {import('../../../../dist/assets/fonts/types.js').FontFamilyAssetsByUniqueKey} */ + const fontFamilyAssetsByUniqueKey = new Map(); + const logger = new SpyLogger(); + + assert.deepStrictEqual( + getOrCreateFontFamilyAssets({ + fontFamilyAssetsByUniqueKey, + family: families[0], + logger, + bold: markdownBold, + }), + { + collectedFontsForMetricsByUniqueKey: new Map(), + family: families[0], + fonts: [], + preloads: [], + }, + ); + assert.deepStrictEqual( + getOrCreateFontFamilyAssets({ + fontFamilyAssetsByUniqueKey, + family: families[1], + logger, + bold: markdownBold, + }), + { + collectedFontsForMetricsByUniqueKey: new Map(), + family: families[0], + fonts: [], + preloads: [], + }, + ); + assert.deepStrictEqual( + getOrCreateFontFamilyAssets({ + fontFamilyAssetsByUniqueKey, + family: families[2], + logger, + bold: markdownBold, + }), + { + collectedFontsForMetricsByUniqueKey: new Map(), + family: families[2], + fonts: [], + preloads: [], + }, + ); + assert.equal(fontFamilyAssetsByUniqueKey.size, 2); + }); + + it('logs warnings for conflicting css variables', () => { + /** @type {import('../../../../dist/assets/fonts/types.js').FontFamilyAssetsByUniqueKey} */ + const fontFamilyAssetsByUniqueKey = new Map(); + const logger = new SpyLogger(); - assert.deepStrictEqual( - getOrCreateFontFamilyAssets({ - fontFamilyAssetsByUniqueKey, - family: families[0], - logger, - bold: markdownBold, - }), - { - collectedFontsForMetricsByUniqueKey: new Map(), - family: families[0], - fonts: [], - preloads: [], - }, - ); - assert.deepStrictEqual( getOrCreateFontFamilyAssets({ fontFamilyAssetsByUniqueKey, - family: families[1], + family: { + name: 'Foo', + uniqueName: 'Foo-xxx', + cssVariable: '--foo', + provider: { + name: 'foo', + resolveFont: () => undefined, + }, + }, logger, bold: markdownBold, - }), - { - collectedFontsForMetricsByUniqueKey: new Map(), - family: families[0], - fonts: [], - preloads: [], - }, - ); - assert.deepStrictEqual( + }); getOrCreateFontFamilyAssets({ fontFamilyAssetsByUniqueKey, - family: families[2], + family: { + name: 'Bar', + uniqueName: 'Bar-xxx', + cssVariable: '--foo', + provider: { + name: 'foo', + resolveFont: () => undefined, + }, + }, logger, bold: markdownBold, - }), - { - collectedFontsForMetricsByUniqueKey: new Map(), - family: families[2], - fonts: [], - preloads: [], - }, - ); - assert.equal(fontFamilyAssetsByUniqueKey.size, 2); + }); + assert.deepStrictEqual( + logger.logs.map((e) => e.type), + ['warn', 'warn'], + ); + }); }); describe('filterAndTransformFontFaces()', () => { From 98dfb61f963d70961dc2b28d786a6280f52603a1 Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Tue, 17 Mar 2026 14:05:01 +0100 Subject: [PATCH 2/3] fix(core): fix Vercel skew protection bug for island hydration URLs (#15931) * fix(core): fix skew protection for island hydration URLs * refactor(core): parse asset links with URL in createAssetLink * chore: add changeset for skew protection fix * chore: trigger CI rerun --- .changeset/tiny-forks-yawn.md | 5 + .../src/core/build/plugins/plugin-manifest.ts | 16 +++- packages/astro/src/core/render/ssr-element.ts | 36 ++++++- .../astro/test/asset-query-params.test.js | 94 +++++++++++++++++++ 4 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 .changeset/tiny-forks-yawn.md diff --git a/.changeset/tiny-forks-yawn.md b/.changeset/tiny-forks-yawn.md new file mode 100644 index 000000000000..e9c01ab1ca6d --- /dev/null +++ b/.changeset/tiny-forks-yawn.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix skew protection query params not being applied to island hydration `component-url` and `renderer-url`, and ensure query params are appended safely for asset URLs with existing search/hash parts. diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 758e6f971a2b..932bdf0dde03 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -149,15 +149,21 @@ async function buildManifest( const routes: SerializedRouteInfo[] = []; const domainLookupTable: Record = {}; - const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); - if (settings.scripts.some((script) => script.stage === 'page')) { - staticFiles.push(entryModules[PAGE_SCRIPT_ID]); - } + const rawEntryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); const assetQueryParams = settings.adapter?.client?.assetQueryParams; const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined; const appendAssetQuery = (pth: string) => (assetQueryString ? `${pth}?${assetQueryString}` : pth); + const entryModules = Object.fromEntries( + Object.entries(rawEntryModules).map(([key, value]) => [ + key, + value ? appendAssetQuery(value) : value, + ]), + ); + if (settings.scripts.some((script) => script.stage === 'page')) { + staticFiles.push(rawEntryModules[PAGE_SCRIPT_ID]); + } const prefixAssetPath = (pth: string) => { let result = ''; @@ -195,7 +201,7 @@ async function buildManifest( const scripts: SerializedRouteInfo['scripts'] = []; if (settings.scripts.some((script) => script.stage === 'page')) { - const src = entryModules[PAGE_SCRIPT_ID]; + const src = rawEntryModules[PAGE_SCRIPT_ID]; scripts.push({ type: 'external', diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index fad347c9f5a8..4e9f1ba1227a 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -3,23 +3,51 @@ import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core import type { SSRElement } from '../../types/public/internal.js'; import type { AssetsPrefix, StylesheetAsset } from '../app/types.js'; +const URL_PARSE_BASE = 'https://astro.build'; + +function splitAssetPath(path: string): { pathname: string; suffix: string } { + const parsed = new URL(path, URL_PARSE_BASE); + const isAbsolute = URL.canParse(path); + const pathname = !isAbsolute && !path.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; + + return { + pathname, + suffix: `${parsed.search}${parsed.hash}`, + }; +} + +function appendQueryParams(path: string, queryParams: URLSearchParams): string { + const queryString = queryParams.toString(); + if (!queryString) { + return path; + } + + const hashIndex = path.indexOf('#'); + const basePath = hashIndex === -1 ? path : path.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : path.slice(hashIndex); + const separator = basePath.includes('?') ? '&' : '?'; + + return `${basePath}${separator}${queryString}${hash}`; +} + export function createAssetLink( href: string, base?: string, assetsPrefix?: AssetsPrefix, queryParams?: URLSearchParams, ): string { + const { pathname, suffix } = splitAssetPath(href); let url = ''; if (assetsPrefix) { - const pf = getAssetsPrefix(fileExtension(href), assetsPrefix); - url = joinPaths(pf, slash(href)); + const pf = getAssetsPrefix(fileExtension(pathname), assetsPrefix); + url = joinPaths(pf, slash(pathname)) + suffix; } else if (base) { - url = prependForwardSlash(joinPaths(base, slash(href))); + url = prependForwardSlash(joinPaths(base, slash(pathname))) + suffix; } else { url = href; } if (queryParams) { - url += '?' + queryParams.toString(); + url = appendQueryParams(url, queryParams); } return url; } diff --git a/packages/astro/test/asset-query-params.test.js b/packages/astro/test/asset-query-params.test.js index ed7fe0f25301..c86ae200bc85 100644 --- a/packages/astro/test/asset-query-params.test.js +++ b/packages/astro/test/asset-query-params.test.js @@ -1,9 +1,17 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; +import woof from './fixtures/multiple-jsx-renderers/renderers/woof/index.mjs'; +import meow from './fixtures/multiple-jsx-renderers/renderers/meow/index.mjs'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; +const multiCdnAssetsPrefix = { + js: 'https://js.example.com', + css: 'https://css.example.com', + fallback: 'https://example.com', +}; + describe('Asset Query Parameters (Adapter Client Config)', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -94,3 +102,89 @@ describe('Asset Query Parameters with Fonts', () => { }); }); }); + +describe('Asset Query Parameters with Islands', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/multiple-jsx-renderers/', + output: 'server', + integrations: [woof({ include: '**/*.woof.jsx' }), meow({ include: '**/*.meow.jsx' })], + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }), + }, + }, + }), + }); + await fixture.build(); + }); + + it('appends assetQueryParams to astro-island component and renderer URLs', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/client-load')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const island = $('astro-island').first(); + + assert.ok(island.length > 0, 'Should have at least one astro-island'); + assert.match( + island.attr('component-url'), + /\?dpl=test-deploy-id/, + `astro-island component-url should include assetQueryParams: ${island.attr('component-url')}`, + ); + assert.match( + island.attr('renderer-url'), + /\?dpl=test-deploy-id/, + `astro-island renderer-url should include assetQueryParams: ${island.attr('renderer-url')}`, + ); + }); +}); + +describe('Asset Query Parameters with Islands and assetsPrefix map', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix/', + output: 'server', + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }), + }, + }, + }), + build: { + assetsPrefix: multiCdnAssetsPrefix, + }, + }); + await fixture.build(); + }); + + it('uses js assetsPrefix for island URLs while appending assetQueryParams', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/custom-base/')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const island = $('astro-island').first(); + + assert.ok(island.length > 0, 'Should have at least one astro-island'); + assert.match( + island.attr('component-url'), + /^https:\/\/js\.example\.com\/_astro\/.*\?dpl=test-deploy-id$/, + `astro-island component-url should use js assetsPrefix and include assetQueryParams: ${island.attr('component-url')}`, + ); + assert.match( + island.attr('renderer-url'), + /^https:\/\/js\.example\.com\/_astro\/.*\?dpl=test-deploy-id$/, + `astro-island renderer-url should use js assetsPrefix and include assetQueryParams: ${island.attr('renderer-url')}`, + ); + }); +}); From c7235747de148806997171f74c46c404c54ffc53 Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Tue, 17 Mar 2026 13:06:00 +0000 Subject: [PATCH 3/3] [ci] format --- packages/astro/src/core/render/ssr-element.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index 4e9f1ba1227a..c7203abe0202 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -8,7 +8,8 @@ const URL_PARSE_BASE = 'https://astro.build'; function splitAssetPath(path: string): { pathname: string; suffix: string } { const parsed = new URL(path, URL_PARSE_BASE); const isAbsolute = URL.canParse(path); - const pathname = !isAbsolute && !path.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; + const pathname = + !isAbsolute && !path.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; return { pathname,