From 6f8f0bc4e22e958ccc2164acb1aa8cce21c43148 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 16 Mar 2026 12:15:43 +0000 Subject: [PATCH 1/9] fix: update peer dependency range (#15934) --- .changeset/loud-towns-camp.md | 12 ++++++++++++ packages/integrations/cloudflare/package.json | 2 +- packages/integrations/markdoc/package.json | 2 +- packages/integrations/mdx/package.json | 2 +- packages/integrations/netlify/package.json | 2 +- packages/integrations/node/package.json | 2 +- packages/integrations/svelte/package.json | 2 +- packages/integrations/vercel/package.json | 2 +- packages/integrations/vue/package.json | 2 +- 9 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 .changeset/loud-towns-camp.md diff --git a/.changeset/loud-towns-camp.md b/.changeset/loud-towns-camp.md new file mode 100644 index 000000000000..4537d1eade8d --- /dev/null +++ b/.changeset/loud-towns-camp.md @@ -0,0 +1,12 @@ +--- +'@astrojs/cloudflare': patch +'@astrojs/markdoc': patch +'@astrojs/netlify': patch +'@astrojs/svelte': patch +'@astrojs/vercel': patch +'@astrojs/node': patch +'@astrojs/mdx': patch +'@astrojs/vue': patch +--- + +Updates the Astro `peerDependencies#astro` to be `6.0.0`. diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index f5f0eaae21c6..89c8b37fb327 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -50,7 +50,7 @@ "vite": "^7.3.1" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0", + "astro": "^6.0.0", "wrangler": "^4.61.1" }, "devDependencies": { diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index be987a6f0862..2630bb35b335 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -71,7 +71,7 @@ "htmlparser2": "^10.1.0" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" }, "devDependencies": { "@types/markdown-it": "^14.1.2", diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index d4f9e424c5a7..37165f94e068 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -49,7 +49,7 @@ "vfile": "^6.0.3" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" }, "devDependencies": { "@shikijs/rehype": "^4.0.0", diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index a9c7a7cac4b8..b88935e04c29 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -50,7 +50,7 @@ "vite": "^7.3.1" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" }, "devDependencies": { "@types/node": "^22.10.6", diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 5a2a5e13e319..50e8dc78d5f2 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -38,7 +38,7 @@ "server-destroy": "^1.0.1" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" }, "devDependencies": { "@types/node": "^22.10.6", diff --git a/packages/integrations/svelte/package.json b/packages/integrations/svelte/package.json index cc313b53ac1e..7ddb2ad7a939 100644 --- a/packages/integrations/svelte/package.json +++ b/packages/integrations/svelte/package.json @@ -49,7 +49,7 @@ "svelte": "^5.53.6" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0", + "astro": "^6.0.0", "svelte": "^5.43.6", "typescript": "^5.3.3" }, diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 9dd68aac96b2..d37ba8b5fc9f 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -55,7 +55,7 @@ "tinyglobby": "^0.2.15" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" }, "devDependencies": { "astro": "workspace:*", diff --git a/packages/integrations/vue/package.json b/packages/integrations/vue/package.json index 2e6ec1d92d6e..63902f2ac400 100644 --- a/packages/integrations/vue/package.json +++ b/packages/integrations/vue/package.json @@ -52,7 +52,7 @@ "vue": "^3.5.29" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0", + "astro": "^6.0.0", "vue": "^3.5.24" }, "engines": { From 925252e8c361a169d1f4dc1e3677b96b9e815dea Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 16 Mar 2026 08:55:39 -0400 Subject: [PATCH 2/9] Fix server island build error (#15888) * fix(astro): make server-island placeholder replacement quote-agnostic Rollup can emit server-island placeholders with either single or double quotes, which caused map injection to be skipped in some adapter builds. Match placeholders quote-agnostically and add a regression test that verifies the maps are materialized in output. * fix(astro): address lint in server-islands placeholder matching * fix(astro): resolve server-island chunk imports in prerender builds * fix(cloudflare): bundle prerender server-island dependencies * chore(changeset): add cloudflare patch note for server islands * chore(changeset): use standard present-tense wording * fix(astro): build server islands in SSR environment * fix(astro): emit server-island chunks before render phase * fix(astro): resolve prerender-discovered islands on Windows --- .changeset/clean-falcons-dance.md | 5 + .changeset/proud-peaches-wish.md | 5 + packages/astro/src/core/build/static-build.ts | 40 +-- .../vite-plugin-server-islands.ts | 247 ++++++------------ .../test/units/build/server-islands.test.js | 155 ++++++++++- packages/integrations/cloudflare/src/index.ts | 5 +- .../astro.config.mjs | 6 + .../src/components/Island.astro | 7 + .../src/pages/index.astro | 11 + .../test/server-island-prerender-deps.test.js | 46 ++++ 10 files changed, 333 insertions(+), 194 deletions(-) create mode 100644 .changeset/clean-falcons-dance.md create mode 100644 .changeset/proud-peaches-wish.md create mode 100644 packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/astro.config.mjs create mode 100644 packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/components/Island.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/pages/index.astro create mode 100644 packages/integrations/cloudflare/test/server-island-prerender-deps.test.js diff --git a/.changeset/clean-falcons-dance.md b/.changeset/clean-falcons-dance.md new file mode 100644 index 000000000000..b3450a95a078 --- /dev/null +++ b/.changeset/clean-falcons-dance.md @@ -0,0 +1,5 @@ +--- +"@astrojs/cloudflare": patch +--- + +Fixes a bug where dependencies imported by prerender-only `server:defer` islands could remain as bare imports in server output, causing module resolution failures in preview and Cloudflare Workers. diff --git a/.changeset/proud-peaches-wish.md b/.changeset/proud-peaches-wish.md new file mode 100644 index 000000000000..7fcf852e236a --- /dev/null +++ b/.changeset/proud-peaches-wish.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fix a bug where `server:defer` could fail at runtime in prerendered pages for some adapters (including Cloudflare), causing errors like `serverIslandMap?.get is not a function`. diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index ed20c6aa0a09..a95321d2322e 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -37,7 +37,7 @@ import { NOOP_MODULE_ID } from './plugins/plugin-noop.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; import type { InputOption } from 'rollup'; import { getSSRAssets } from './internal.js'; -import { serverIslandPlaceholderMap } from '../server-islands/vite-plugin-server-islands.js'; +import { SERVER_ISLAND_MAP_MARKER } from '../server-islands/vite-plugin-server-islands.js'; const PRERENDER_ENTRY_FILENAME_PREFIX = 'prerender-entry'; @@ -73,7 +73,7 @@ function extractRelevantChunks( const needsContentInjection = chunk.code.includes(LINKS_PLACEHOLDER); const needsManifestInjection = chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID); - const needsServerIslandInjection = chunk.code.includes(serverIslandPlaceholderMap); + const needsServerIslandInjection = chunk.code.includes(SERVER_ISLAND_MAP_MARKER); if (needsContentInjection || needsManifestInjection || needsServerIslandInjection) { extracted.push({ @@ -138,15 +138,15 @@ export async function viteBuild(opts: StaticBuildOptions) { * * ## Build Order & Dependencies * - * 1. **SSR Environment** (built first) - * - Generates the server runtime entry point - * - Outputs to server directory - * - * 2. **Prerender Environment** (built second) + * 1. **Prerender Environment** (built first) * - Generates code for static prerenderable routes * - Entry: `astro/entrypoints/prerender` * - Outputs to `.prerender/` in server directory * + * 2. **SSR Environment** (built second) + * - Generates the server runtime entry point + * - Outputs to server directory + * * 3. **Client Environment** (built last) * - MUST be built after SSR/prerender because client inputs are discovered during those builds * - During SSR/prerender, Astro discovers: @@ -325,6 +325,19 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter // This takes precedence over platform plugin fallbacks (e.g., Cloudflare) builder: { async buildApp(builder) { + // Build prerender environment for static generation + settings.timer.start('Prerender build'); + let prerenderOutput = await builder.build(builder.environments.prerender); + settings.timer.end('Prerender build'); + + // Extract prerender entry filename and store in internals + extractPrerenderEntryFileName(internals, prerenderOutput); + + // Extract chunks needing injection, then release output for GC + const prerenderOutputs = viteBuildReturnToRollupOutputs(prerenderOutput); + const prerenderChunks = extractRelevantChunks(prerenderOutputs, true); + prerenderOutput = undefined as any; + // Build ssr environment for server output (only for non-static builds) let ssrChunks: BuildInternals['extractedChunks'] = []; if (settings.buildOutput !== 'static') { @@ -339,19 +352,6 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter ssrOutput = undefined as any; } - // Build prerender environment for static generation - settings.timer.start('Prerender build'); - let prerenderOutput = await builder.build(builder.environments.prerender); - settings.timer.end('Prerender build'); - - // Extract prerender entry filename and store in internals - extractPrerenderEntryFileName(internals, prerenderOutput); - - // Extract chunks needing injection, then release output for GC - const prerenderOutputs = viteBuildReturnToRollupOutputs(prerenderOutput); - const prerenderChunks = extractRelevantChunks(prerenderOutputs, true); - prerenderOutput = undefined as any; - const ssrPlugins = builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]?.config.plugins ?? []; buildPostHooks = ssrPlugins diff --git a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts index 2f51e7c20823..cc630c6beade 100644 --- a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts +++ b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts @@ -1,17 +1,17 @@ -import fs from 'node:fs'; import type { ConfigEnv, DevEnvironment, Plugin as VitePlugin } from 'vite'; -import { getPrerenderOutputDirectory, getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroPluginOptions } from '../../types/astro.js'; import type { AstroPluginMetadata } from '../../vite-plugin-astro/index.js'; -import { AstroError, AstroErrorData } from '../errors/index.js'; -import { appendForwardSlash } from '../path.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; +import { AstroError, AstroErrorData } from '../errors/index.js'; export const SERVER_ISLAND_MANIFEST = 'virtual:astro:server-island-manifest'; const RESOLVED_SERVER_ISLAND_MANIFEST = '\0' + SERVER_ISLAND_MANIFEST; -export const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; +const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; const serverIslandPlaceholderNameMap = "'$$server-islands-name-map$$'"; +export const SERVER_ISLAND_MAP_MARKER = '$$server-islands-map$$'; +const serverIslandMapReplaceExp = /['"]\$\$server-islands-map\$\$['"]/g; +const serverIslandNameMapReplaceExp = /['"]\$\$server-islands-name-map\$\$['"]/g; function createServerIslandImportMapSource( entries: Iterable<[string, string]>, @@ -32,22 +32,31 @@ function createNameMapSource(entries: Iterable<[string, string]>) { export function vitePluginServerIslands({ settings }: AstroPluginOptions): VitePlugin { let command: ConfigEnv['command'] = 'serve'; let ssrEnvironment: DevEnvironment | null = null; - const referenceIdMap = new Map(); - // Maps populated during transform to discover server island components. - // serverIslandMap: displayName → resolvedPath (e.g. 'Island' → '/abs/path/Island.astro') - // serverIslandNameMap: resolvedPath → displayName (reverse of above) + // serverIslandMap: displayName -> resolvedPath const serverIslandMap = new Map(); + // serverIslandNameMap: resolvedPath -> displayName const serverIslandNameMap = new Map(); + // resolvedPath -> source import details used for Rollup emission + const serverIslandSourceMap = new Map(); + // resolvedPath -> rollup reference id + const referenceIdMap = new Map(); - // Resolved island chunk filenames discovered across SSR + prerender builds. - // Map of displayName → chunk fileName (e.g. 'Island' → 'chunks/Island_abc123.mjs') - const resolvedIslandImports = new Map(); - - // Reference to the SSR manifest chunk, saved during SSR's generateBundle. - // Patched in-memory during prerender's generateBundle so test capture plugins - // and other post plugins observe final code without placeholders. - let ssrManifestChunk: { code: string; fileName: string } | null = null; + function ensureServerIslandReferenceIds(ctx: { + emitFile: (file: { type: 'chunk'; id: string; importer?: string; name?: string }) => string; + }) { + for (const [resolvedPath, islandName] of serverIslandNameMap) { + if (referenceIdMap.has(resolvedPath)) continue; + const source = serverIslandSourceMap.get(resolvedPath); + const referenceId = ctx.emitFile({ + type: 'chunk', + id: source?.id ?? resolvedPath, + importer: source?.importer, + name: islandName, + }); + referenceIdMap.set(resolvedPath, referenceId); + } + } return { name: 'astro:server-islands', @@ -55,6 +64,13 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP config(_config, { command: _command }) { command = _command; }, + buildStart() { + if (command !== 'build' || this.environment?.name !== ASTRO_VITE_ENVIRONMENT_NAMES.ssr) { + return; + } + + ensureServerIslandReferenceIds(this); + }, configureServer(server) { ssrEnvironment = server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]; }, @@ -80,17 +96,14 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP transform: { filter: { id: { - include: [ - // Allows server islands in astro and mdx files - /\.(astro|mdx)$/, - new RegExp(`^${RESOLVED_SERVER_ISLAND_MANIFEST}$`), - ], + include: [/\.(astro|mdx)$/, new RegExp(`^${RESOLVED_SERVER_ISLAND_MANIFEST}$`)], }, }, async handler(_code, id) { const info = this.getModuleInfo(id); - const astro = info ? (info.meta.astro as AstroPluginMetadata['astro']) : undefined; + const isBuildSsr = + command === 'build' && this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr; if (astro) { for (const comp of astro.serverComponents) { @@ -98,38 +111,33 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP if (!settings.adapter) { throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands); } + let name = comp.localName; let idx = 1; - - while (true) { - // Name not taken, let's use it. - if (!serverIslandMap.has(name)) { - break; - } - // Increment a number onto the name: Avatar -> Avatar1 + while (serverIslandMap.has(name)) { name += idx++; } - // Track the island component for later build/dev use serverIslandNameMap.set(comp.resolvedPath, name); serverIslandMap.set(name, comp.resolvedPath); + serverIslandSourceMap.set(comp.resolvedPath, { id: comp.specifier, importer: id }); + } - if (command === 'build') { - const referenceId = this.emitFile({ - type: 'chunk', - id: comp.specifier, - importer: id, - name: comp.localName, - }); - referenceIdMap.set(comp.resolvedPath, referenceId); - } + if (isBuildSsr && !referenceIdMap.has(comp.resolvedPath)) { + const islandName = serverIslandNameMap.get(comp.resolvedPath); + const source = serverIslandSourceMap.get(comp.resolvedPath); + const referenceId = this.emitFile({ + type: 'chunk', + id: source?.id ?? comp.resolvedPath, + importer: source?.importer, + name: islandName, + }); + referenceIdMap.set(comp.resolvedPath, referenceId); } } } if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0 && ssrEnvironment) { - // In dev, we need to clear the module graph so that Vite knows to re-transform - // the module with the new island information. const mod = ssrEnvironment.moduleGraph.getModuleById(RESOLVED_SERVER_ISLAND_MANIFEST); if (mod) { ssrEnvironment.moduleGraph.invalidateModule(mod); @@ -139,13 +147,12 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP if (id === RESOLVED_SERVER_ISLAND_MANIFEST) { if (command === 'build' && settings.buildOutput) { const hasServerIslands = serverIslandNameMap.size > 0; - // Error if there are server islands but no adapter provided. if (hasServerIslands && settings.buildOutput !== 'server') { throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands); } } - if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0) { + if (command !== 'build' && serverIslandNameMap.size > 0 && serverIslandMap.size > 0) { const mapSource = createServerIslandImportMapSource( serverIslandMap, (fileName) => fileName, @@ -154,9 +161,10 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP return { code: ` - export const serverIslandMap = ${mapSource}; - \n\nexport const serverIslandNameMap = ${nameMapSource}; - `, + export const serverIslandMap = ${mapSource}; + + export const serverIslandNameMap = ${nameMapSource}; + `, }; } } @@ -164,149 +172,48 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP }, renderChunk(code, chunk) { - if (code.includes(serverIslandPlaceholderMap)) { - if (command === 'build') { - if (referenceIdMap.size === 0) { - // SSR may not discover islands if they only appear in prerendered pages. - // Leave placeholders for post-build patching in that case. - return; - } + if (!code.includes(SERVER_ISLAND_MAP_MARKER)) return; + + if (command === 'build') { + const envName = this.environment?.name; + let mapSource: string; + if (envName === ASTRO_VITE_ENVIRONMENT_NAMES.ssr) { const isRelativeChunk = !chunk.isEntry; const dots = isRelativeChunk ? '..' : '.'; const mapEntries: Array<[string, string]> = []; + for (const [resolvedPath, referenceId] of referenceIdMap) { const fileName = this.getFileName(referenceId); const islandName = serverIslandNameMap.get(resolvedPath); if (!islandName) continue; - if (!resolvedIslandImports.has(islandName)) { - resolvedIslandImports.set(islandName, fileName); - } mapEntries.push([islandName, fileName]); } - const mapSource = createServerIslandImportMapSource( + + mapSource = createServerIslandImportMapSource( mapEntries, (fileName) => `${dots}/${fileName}`, ); - const nameMapSource = createNameMapSource(serverIslandNameMap); - - return { - code: code - .replace(serverIslandPlaceholderMap, mapSource) - .replace(serverIslandPlaceholderNameMap, nameMapSource), - map: null, - }; + } else { + mapSource = createServerIslandImportMapSource(serverIslandMap, (fileName) => fileName); } - // Dev mode: fast-path to empty map replacement + + const nameMapSource = createNameMapSource(serverIslandNameMap); + return { code: code - .replace(serverIslandPlaceholderMap, 'new Map();') - .replace(serverIslandPlaceholderNameMap, 'new Map()'), + .replace(serverIslandMapReplaceExp, mapSource) + .replace(serverIslandNameMapReplaceExp, nameMapSource), map: null, }; } - }, - - generateBundle(_options, bundle) { - const envName = this.environment?.name; - - if (envName === ASTRO_VITE_ENVIRONMENT_NAMES.ssr) { - for (const chunk of Object.values(bundle)) { - if (chunk.type === 'chunk' && chunk.code.includes(serverIslandPlaceholderMap)) { - ssrManifestChunk = chunk; - break; - } - } - } - - if (envName === ASTRO_VITE_ENVIRONMENT_NAMES.prerender && ssrManifestChunk) { - if (resolvedIslandImports.size > 0) { - const isRelativeChunk = ssrManifestChunk.fileName.includes('/'); - const dots = isRelativeChunk ? '..' : '.'; - const mapSource = createServerIslandImportMapSource( - resolvedIslandImports, - (fileName) => `${dots}/${fileName}`, - ); - const nameMapSource = createNameMapSource(serverIslandNameMap); - - ssrManifestChunk.code = ssrManifestChunk.code - .replace(serverIslandPlaceholderMap, mapSource) - .replace(serverIslandPlaceholderNameMap, nameMapSource); - } else { - ssrManifestChunk.code = ssrManifestChunk.code - .replace(serverIslandPlaceholderMap, 'new Map()') - .replace(serverIslandPlaceholderNameMap, 'new Map()'); - } - } - }, - - api: { - /** - * Post-build hook that patches SSR chunks containing server island placeholders. - * - * During build, SSR can run before all server islands are discovered (e.g. islands - * only used in prerendered pages). This hook runs after SSR + prerender builds and: - * 1) replaces placeholders with the complete map of discovered islands - * 2) copies island chunks emitted in prerender into the SSR output directory - * - * Two cases: - * 1. Islands were discovered: Replace placeholders with real import maps. - * 2. No islands found: Replace placeholders with empty maps. - */ - async buildPostHook({ - chunks, - mutate, - }: { - chunks: Array<{ fileName: string; code: string; prerender: boolean }>; - mutate: (fileName: string, code: string, prerender: boolean) => void; - }) { - // Find SSR chunks that still have the placeholder (not prerender chunks) - const ssrChunkWithPlaceholder = chunks.find( - (c) => !c.prerender && c.code.includes(serverIslandPlaceholderMap), - ); - - if (!ssrChunkWithPlaceholder) { - return; - } - if (resolvedIslandImports.size > 0) { - // Server islands were discovered across SSR/prerender builds. - // Construct import paths relative to the SSR chunk's location. - const isRelativeChunk = ssrChunkWithPlaceholder.fileName.includes('/'); - const dots = isRelativeChunk ? '..' : '.'; - - const mapSource = createServerIslandImportMapSource( - resolvedIslandImports, - (fileName) => `${dots}/${fileName}`, - ); - const nameMapSource = createNameMapSource(serverIslandNameMap); - - const newCode = ssrChunkWithPlaceholder.code - .replace(serverIslandPlaceholderMap, mapSource) - .replace(serverIslandPlaceholderNameMap, nameMapSource); - - mutate(ssrChunkWithPlaceholder.fileName, newCode, false); - - const serverOutputDir = getServerOutputDirectory(settings); - const prerenderOutputDir = getPrerenderOutputDirectory(settings); - for (const [, fileName] of resolvedIslandImports) { - const srcPath = new URL(fileName, appendForwardSlash(prerenderOutputDir.toString())); - const destPath = new URL(fileName, appendForwardSlash(serverOutputDir.toString())); - - if (!fs.existsSync(srcPath)) continue; - const destDir = new URL('./', destPath); - await fs.promises.mkdir(destDir, { recursive: true }); - await fs.promises.copyFile(srcPath, destPath); - } - } else { - // No server islands found — replace placeholders with empty maps - const newCode = ssrChunkWithPlaceholder.code - .replace(serverIslandPlaceholderMap, 'new Map()') - .replace(serverIslandPlaceholderNameMap, 'new Map()'); - - mutate(ssrChunkWithPlaceholder.fileName, newCode, false); - } - }, + return { + code: code + .replace(serverIslandMapReplaceExp, 'new Map();') + .replace(serverIslandNameMapReplaceExp, 'new Map()'), + map: null, + }; }, }; } diff --git a/packages/astro/test/units/build/server-islands.test.js b/packages/astro/test/units/build/server-islands.test.js index 56a9cfdd15d1..319ff88a0075 100644 --- a/packages/astro/test/units/build/server-islands.test.js +++ b/packages/astro/test/units/build/server-islands.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { AstroBuilder } from '../../../dist/core/build/index.js'; import { parseRoute } from '../../../dist/core/routing/parse-route.js'; import { createBasicSettings, defaultLogger } from '../test-utils.js'; @@ -22,6 +22,25 @@ async function readFilesRecursive(dir) { return files.flat(); } +function forceDoubleQuotedServerIslandPlaceholders() { + return { + name: 'force-double-quoted-server-island-placeholders', + enforce: 'pre', + renderChunk(code) { + if (!code.includes("'$$server-islands-map$$'")) { + return; + } + + return { + code: code + .replace(/'\$\$server-islands-map\$\$'/g, () => '"$$server-islands-map$$"') + .replace(/'\$\$server-islands-name-map\$\$'/g, () => '"$$server-islands-name-map$$"'), + map: null, + }; + }, + }; +} + describe('Build: Server islands in prerendered pages', () => { it('builds successfully when server:defer is only used in prerendered pages', async () => { const root = new URL('./_temp-fixtures/', import.meta.url); @@ -115,8 +134,140 @@ describe('Build: Server islands in prerendered pages', () => { `Server island manifest should contain Island component but got:\n${manifestContent}`, ); assert.ok( - !manifestContent.includes('$$server-islands-map$$'), + /serverIslandMap\s*=\s*new Map\(/.test(manifestContent), + `Server island map should be materialized in output but got:\n${manifestContent}`, + ); + assert.ok( + /serverIslandNameMap\s*=\s*new Map\(/.test(manifestContent), + `Server island name map should be materialized in output but got:\n${manifestContent}`, + ); + assert.ok( + !manifestContent.includes('$$server-islands-map$$') && + !manifestContent.includes('$$server-islands-name-map$$'), + `Server island manifest should not include placeholders but got:\n${manifestContent}`, + ); + + assert.ok(manifestFilePath, 'Server island manifest chunk path should exist'); + const manifestModule = await import(pathToFileURL(manifestFilePath).href); + const islandLoader = manifestModule.serverIslandMap.get('Island'); + assert.equal(typeof islandLoader, 'function', 'Island loader should be a function'); + await assert.doesNotReject( + async () => islandLoader(), + 'Server island chunk import should resolve at runtime', + ); + }); + + it('replaces server island placeholders even when quote style changes in generated chunks', async () => { + const root = new URL('./_temp-fixtures/', import.meta.url); + + const settings = await createBasicSettings({ + root: fileURLToPath(root), + output: 'server', + adapter: { + name: 'test-adapter', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: 'test-adapter', + serverEntrypoint: 'astro/app', + exports: ['manifest', 'createApp'], + supportedAstroFeatures: { + serverOutput: 'stable', + }, + adapterFeatures: { + buildOutput: 'server', + }, + }); + }, + }, + }, + vite: { + plugins: [ + virtualAstroModules(root, { + 'src/components/Island.astro': [ + '---', + '---', + '

I am a server island

', + ].join('\n'), + 'src/pages/index.astro': [ + '---', + "import Island from '../components/Island.astro';", + 'export const prerender = true;', + '---', + '', + 'Test', + '', + '', + '', + '', + ].join('\n'), + }), + forceDoubleQuotedServerIslandPlaceholders(), + ], + }, + }); + + const routesList = { + routes: [ + parseRoute('index.astro', settings, { + component: 'src/pages/index.astro', + prerender: true, + }), + ], + }; + + const { injectServerIslandRoute } = await import( + '../../../dist/core/server-islands/endpoint.js' + ); + injectServerIslandRoute(settings.config, routesList); + + process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='; + + try { + const builder = new AstroBuilder(settings, { + logger: defaultLogger, + mode: 'production', + runtimeMode: 'production', + teardownCompiler: false, + routesList, + sync: false, + }); + await builder.run(); + } finally { + delete process.env.ASTRO_KEY; + } + + const serverOutputDir = fileURLToPath(settings.config.build.server); + const outputFiles = await readFilesRecursive(serverOutputDir); + const manifestFilePath = outputFiles.find((file) => file.includes('server-island-manifest')); + const manifestContent = manifestFilePath ? await fs.readFile(manifestFilePath, 'utf-8') : null; + + assert.ok(manifestContent, 'Server island manifest chunk should be emitted'); + assert.ok( + /['"]Island['"]/.test(manifestContent), + `Server island manifest should contain Island component but got:\n${manifestContent}`, + ); + assert.ok( + /serverIslandMap\s*=\s*new Map\(/.test(manifestContent), + `Server island map should be materialized in output but got:\n${manifestContent}`, + ); + assert.ok( + /serverIslandNameMap\s*=\s*new Map\(/.test(manifestContent), + `Server island name map should be materialized in output but got:\n${manifestContent}`, + ); + assert.ok( + !manifestContent.includes('$$server-islands-map$$') && + !manifestContent.includes('$$server-islands-name-map$$'), `Server island manifest should not include placeholders but got:\n${manifestContent}`, ); + + assert.ok(manifestFilePath, 'Server island manifest chunk path should exist'); + const manifestModule = await import(pathToFileURL(manifestFilePath).href); + const islandLoader = manifestModule.serverIslandMap.get('Island'); + assert.equal(typeof islandLoader, 'function', 'Island loader should be a function'); + await assert.doesNotReject( + async () => islandLoader(), + 'Server island chunk import should resolve at runtime', + ); }); }); diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 1886b5dbd134..c94f71bacb9e 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -294,10 +294,11 @@ export default function createIntegration({ { enforce: 'post', name: '@astrojs/cloudflare:cf-externals', - applyToEnvironment: (environment) => environment.name === 'ssr', + applyToEnvironment: (environment) => + environment.name === 'ssr' || environment.name === 'prerender', config(conf) { if (conf.ssr) { - // Cloudflare does not support externalizing modules in the ssr environment + // Cloudflare does not support externalizing modules in server environments conf.ssr.external = undefined; conf.ssr.noExternal = true; } diff --git a/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/astro.config.mjs new file mode 100644 index 000000000000..8489d7d3348f --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/astro.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; + +export default defineConfig({ + adapter: cloudflare(), +}); diff --git a/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/components/Island.astro b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/components/Island.astro new file mode 100644 index 000000000000..e81f7b07c0b3 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/components/Island.astro @@ -0,0 +1,7 @@ +--- +import { uneval } from 'devalue'; + +const encoded = uneval({ message: 'hello world' }); +--- + +

Encoded: {encoded}

diff --git a/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/pages/index.astro new file mode 100644 index 000000000000..892bc9336cb4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/server-island-prerender-deps/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +import Island from '../components/Island.astro'; + +export const prerender = true; +--- + + + + + + diff --git a/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js b/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js new file mode 100644 index 000000000000..a6111f43f540 --- /dev/null +++ b/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { loadFixture } from './_test-utils.js'; + +async function readFilesRecursive(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return readFilesRecursive(fullPath); + } + return [fullPath]; + }), + ); + return files.flat(); +} + +describe('Cloudflare server island prerender dependencies', () => { + it('bundles third-party imports for prerender-only server islands', async () => { + const fixture = await loadFixture({ + root: './fixtures/server-island-prerender-deps/', + }); + + await fixture.build(); + + const serverOutputDir = fileURLToPath(fixture.config.build.server); + const outputFiles = await readFilesRecursive(serverOutputDir); + const islandChunkPath = outputFiles.find((file) => { + const normalized = file.replaceAll(path.sep, '/'); + return normalized.includes('/chunks/Island_') && normalized.endsWith('.mjs'); + }); + + assert.ok(islandChunkPath, 'Server island chunk should be emitted'); + + const islandChunkCode = await fs.readFile(islandChunkPath, 'utf-8'); + assert.equal( + islandChunkCode.includes("from 'devalue'") || islandChunkCode.includes('from "devalue"'), + false, + `Server island chunk should not keep bare devalue imports:\n${islandChunkCode}`, + ); + }); +}); From 325901e623462babd8d07ba7527e141e08ef1901 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 16 Mar 2026 13:20:55 +0000 Subject: [PATCH 3/9] fix(svg): track inline styles for CSP (#15933) --- .changeset/jolly-dragons-hear.md | 5 +++ packages/astro/src/assets/runtime.ts | 27 ++++++++++++-- packages/astro/src/assets/utils/svg.ts | 37 +++++++++++++++---- packages/astro/src/content/runtime.ts | 8 +++- packages/astro/test/csp.test.js | 37 +++++++++++++++++++ .../test/fixtures/csp/src/assets/test.svg | 4 ++ .../test/fixtures/csp/src/pages/svg.astro | 17 +++++++++ 7 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 .changeset/jolly-dragons-hear.md create mode 100644 packages/astro/test/fixtures/csp/src/assets/test.svg create mode 100644 packages/astro/test/fixtures/csp/src/pages/svg.astro diff --git a/.changeset/jolly-dragons-hear.md b/.changeset/jolly-dragons-hear.md new file mode 100644 index 000000000000..1dd81a7c68ec --- /dev/null +++ b/.changeset/jolly-dragons-hear.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where ` + diff --git a/packages/astro/test/fixtures/csp/src/pages/svg.astro b/packages/astro/test/fixtures/csp/src/pages/svg.astro new file mode 100644 index 000000000000..488a99c7c503 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/svg.astro @@ -0,0 +1,17 @@ +--- +import Icon from "../assets/test.svg" +--- + + + + + + Image CSP Test + + +
+

Image with layout

+ +
+ + From 09ecdd7c5e5f243119a821e28b07e0cf81f8b388 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:36:35 -0700 Subject: [PATCH 4/9] [ci] release (#15889) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/clean-falcons-dance.md | 5 -- .changeset/curly-mails-lie.md | 5 -- .changeset/fair-buttons-float.md | 5 -- .changeset/fair-points-raise.md | 5 -- .changeset/few-houses-dance.md | 5 -- .changeset/fix-before-hydration-client.md | 5 -- .changeset/jolly-dragons-hear.md | 5 -- .changeset/loud-towns-camp.md | 12 --- .changeset/odd-bears-chew.md | 5 -- .changeset/polite-poets-push.md | 5 -- .changeset/proud-peaches-wish.md | 5 -- .changeset/slick-pans-smash.md | 5 -- .changeset/small-jeans-whisper.md | 5 -- .changeset/soft-lamps-warn.md | 5 -- .changeset/tame-phones-run.md | 5 -- .changeset/ten-rats-wave.md | 5 -- .changeset/wet-cases-grow.md | 6 -- examples/basics/package.json | 2 +- examples/blog/package.json | 4 +- examples/component/package.json | 2 +- examples/container-with-vitest/package.json | 2 +- examples/framework-alpine/package.json | 2 +- examples/framework-multiple/package.json | 8 +- examples/framework-preact/package.json | 4 +- examples/framework-react/package.json | 2 +- examples/framework-solid/package.json | 2 +- examples/framework-svelte/package.json | 4 +- examples/framework-vue/package.json | 4 +- examples/hackernews/package.json | 4 +- examples/integration/package.json | 2 +- examples/minimal/package.json | 2 +- examples/portfolio/package.json | 2 +- examples/ssr/package.json | 6 +- examples/starlog/package.json | 2 +- examples/toolbar-app/package.json | 2 +- examples/with-markdoc/package.json | 4 +- examples/with-mdx/package.json | 6 +- examples/with-nanostores/package.json | 4 +- examples/with-tailwindcss/package.json | 4 +- examples/with-vitest/package.json | 2 +- packages/astro/CHANGELOG.md | 24 ++++++ packages/astro/package.json | 2 +- packages/create-astro/CHANGELOG.md | 6 ++ packages/create-astro/package.json | 2 +- packages/integrations/cloudflare/CHANGELOG.md | 13 +++ packages/integrations/cloudflare/package.json | 2 +- packages/integrations/markdoc/CHANGELOG.md | 6 ++ packages/integrations/markdoc/package.json | 2 +- packages/integrations/mdx/CHANGELOG.md | 6 ++ packages/integrations/mdx/package.json | 2 +- packages/integrations/netlify/CHANGELOG.md | 9 +++ packages/integrations/netlify/package.json | 2 +- packages/integrations/node/CHANGELOG.md | 6 ++ packages/integrations/node/package.json | 2 +- packages/integrations/preact/CHANGELOG.md | 6 ++ packages/integrations/preact/package.json | 2 +- packages/integrations/svelte/CHANGELOG.md | 6 ++ packages/integrations/svelte/package.json | 2 +- packages/integrations/vercel/CHANGELOG.md | 6 ++ packages/integrations/vercel/package.json | 2 +- packages/integrations/vue/CHANGELOG.md | 6 ++ packages/integrations/vue/package.json | 2 +- .../language-tools/astro-check/CHANGELOG.md | 9 +++ .../language-tools/astro-check/package.json | 4 +- .../language-server/CHANGELOG.md | 6 ++ .../language-server/package.json | 2 +- packages/language-tools/vscode/CHANGELOG.md | 6 ++ packages/language-tools/vscode/package.json | 4 +- pnpm-lock.yaml | 80 +++++++++---------- 69 files changed, 209 insertions(+), 187 deletions(-) delete mode 100644 .changeset/clean-falcons-dance.md delete mode 100644 .changeset/curly-mails-lie.md delete mode 100644 .changeset/fair-buttons-float.md delete mode 100644 .changeset/fair-points-raise.md delete mode 100644 .changeset/few-houses-dance.md delete mode 100644 .changeset/fix-before-hydration-client.md delete mode 100644 .changeset/jolly-dragons-hear.md delete mode 100644 .changeset/loud-towns-camp.md delete mode 100644 .changeset/odd-bears-chew.md delete mode 100644 .changeset/polite-poets-push.md delete mode 100644 .changeset/proud-peaches-wish.md delete mode 100644 .changeset/slick-pans-smash.md delete mode 100644 .changeset/small-jeans-whisper.md delete mode 100644 .changeset/soft-lamps-warn.md delete mode 100644 .changeset/tame-phones-run.md delete mode 100644 .changeset/ten-rats-wave.md delete mode 100644 .changeset/wet-cases-grow.md diff --git a/.changeset/clean-falcons-dance.md b/.changeset/clean-falcons-dance.md deleted file mode 100644 index b3450a95a078..000000000000 --- a/.changeset/clean-falcons-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@astrojs/cloudflare": patch ---- - -Fixes a bug where dependencies imported by prerender-only `server:defer` islands could remain as bare imports in server output, causing module resolution failures in preview and Cloudflare Workers. diff --git a/.changeset/curly-mails-lie.md b/.changeset/curly-mails-lie.md deleted file mode 100644 index 70f57323fd3d..000000000000 --- a/.changeset/curly-mails-lie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Fix dev routing for `server:defer` islands when adapters opt into handling prerendered routes in Astro core. Server island requests are now treated as prerender-handler eligible so prerendered pages using `prerenderEnvironment: 'node'` can load island content without `400` errors. diff --git a/.changeset/fair-buttons-float.md b/.changeset/fair-buttons-float.md deleted file mode 100644 index f23f307d54a8..000000000000 --- a/.changeset/fair-buttons-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Fixes `astro:actions` validation to check resolved routes, so projects using default static output with at least one `prerender = false` page or endpoint no longer fail during startup. diff --git a/.changeset/fair-points-raise.md b/.changeset/fair-points-raise.md deleted file mode 100644 index aab0298e9d1a..000000000000 --- a/.changeset/fair-points-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Avoid a `MaxListenersExceededWarning` during `astro dev` startup by increasing the shared Vite watcher listener limit when attaching content server listeners. diff --git a/.changeset/few-houses-dance.md b/.changeset/few-houses-dance.md deleted file mode 100644 index d0bf72bde5c0..000000000000 --- a/.changeset/few-houses-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@astrojs/preact': patch ---- - -Fix `useId()` collisions across multiple Astro islands by seeding a unique per-island root mask for Preact SSR and hydration. diff --git a/.changeset/fix-before-hydration-client.md b/.changeset/fix-before-hydration-client.md deleted file mode 100644 index 0c2d37e862d8..000000000000 --- a/.changeset/fix-before-hydration-client.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Emit the `before-hydration` script chunk for the `client` Vite environment. The chunk was only emitted for `prerender` and `ssr` environments, causing a 404 when browsers tried to load it. This broke hydration for any integration using `injectScript('before-hydration', ...)`, including Lit SSR. diff --git a/.changeset/jolly-dragons-hear.md b/.changeset/jolly-dragons-hear.md deleted file mode 100644 index 1dd81a7c68ec..000000000000 --- a/.changeset/jolly-dragons-hear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Fixes an issue where ` +) : null} + +{view === 'Gallery' && } diff --git a/packages/language-tools/vscode/test/grammar/fixtures/style/expression.astro.snap b/packages/language-tools/vscode/test/grammar/fixtures/style/expression.astro.snap new file mode 100644 index 000000000000..b65095a754fe --- /dev/null +++ b/packages/language-tools/vscode/test/grammar/fixtures/style/expression.astro.snap @@ -0,0 +1,38 @@ +>--- +#^^^ source.astro comment +>const view = 'Gallery'; +#^^^^^^^^^^^^^^^^^^^^^^^^ source.astro meta.embedded.block.astro source.ts +>--- +#^^^ source.astro comment +> +>{view === 'Gallery' ? ( +#^ source.astro punctuation.section.embedded.begin.astro +# ^^^^^^^^^^^^^^^^^^^^^^^ source.astro meta.embedded.expression.astro source.tsx +> +#^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.embedded.block.astro source.css +# ^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro punctuation.definition.tag.begin.astro +# ^^^^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro entity.name.tag.astro +# ^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro punctuation.definition.tag.end.astro +>) : null} +#^^^^^^^^ source.astro meta.embedded.expression.astro source.tsx +# ^ source.astro punctuation.section.embedded.end.astro +> +>{view === 'Gallery' && } +#^ source.astro punctuation.section.embedded.begin.astro +# ^^^^^^^^^^^^^^^^^^^^^^ source.astro meta.embedded.expression.astro source.tsx +# ^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.start.astro punctuation.definition.tag.begin.astro +# ^^^^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.start.astro entity.name.tag.astro +# ^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.start.astro punctuation.definition.tag.end.astro +# ^^^^^^^^^^^^^^^^^^^^^^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.embedded.block.astro source.css +# ^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro punctuation.definition.tag.begin.astro +# ^^^^^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro entity.name.tag.astro +# ^ source.astro meta.embedded.expression.astro source.tsx meta.scope.tag.style.astro meta.style.astro meta.tag.end.astro punctuation.definition.tag.end.astro +# ^ source.astro punctuation.section.embedded.end.astro +> \ No newline at end of file From 3c157f615f6abc9a4461e4ee628279284bca8649 Mon Sep 17 00:00:00 2001 From: 0xRozier Date: Mon, 16 Mar 2026 14:13:55 +0000 Subject: [PATCH 7/9] [ci] format --- .../language-tools/vscode/syntaxes/astro.tmLanguage.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/language-tools/vscode/syntaxes/astro.tmLanguage.json b/packages/language-tools/vscode/syntaxes/astro.tmLanguage.json index 3f2e12ae4cb3..8471d36eb862 100644 --- a/packages/language-tools/vscode/syntaxes/astro.tmLanguage.json +++ b/packages/language-tools/vscode/syntaxes/astro.tmLanguage.json @@ -1,9 +1,7 @@ { "name": "Astro", "scopeName": "source.astro", - "fileTypes": [ - "astro" - ], + "fileTypes": ["astro"], "injections": { "L:(meta.script.astro) (meta.lang.json) - (meta.embedded.block source)": { "patterns": [ @@ -901,4 +899,4 @@ ] } } -} \ No newline at end of file +} From 421e8de6e9e13ca45864c2d3deed7e98e3ee6138 Mon Sep 17 00:00:00 2001 From: Felmon Fekadu <125313419+FelmonFekadu@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:44:54 -0600 Subject: [PATCH 8/9] fix(language-server): defer HTML expression completions to TS (#15927) Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com> Co-authored-by: Felmon Fekadu --- .changeset/green-forks-laugh.md | 6 +++++ .../language-server/src/plugins/html.ts | 13 ++++++++-- .../test/typescript/completions.test.ts | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changeset/green-forks-laugh.md diff --git a/.changeset/green-forks-laugh.md b/.changeset/green-forks-laugh.md new file mode 100644 index 000000000000..1bd99ddf2e05 --- /dev/null +++ b/.changeset/green-forks-laugh.md @@ -0,0 +1,6 @@ +--- +'@astrojs/language-server': patch +'astro-vscode': patch +--- + +Fixes completions sometimes not working inside the `href` attribute diff --git a/packages/language-tools/language-server/src/plugins/html.ts b/packages/language-tools/language-server/src/plugins/html.ts index 783e2f5ab373..888f71087c1b 100644 --- a/packages/language-tools/language-server/src/plugins/html.ts +++ b/packages/language-tools/language-server/src/plugins/html.ts @@ -5,7 +5,7 @@ import * as html from 'vscode-html-languageservice'; import { URI, Utils } from 'vscode-uri'; import { AstroVirtualCode } from '../core/index.js'; import { astroAttributes, astroElements, classListAttribute } from './html-data.js'; -import { isInComponentStartTag } from './utils.js'; +import { isInComponentStartTag, isInsideExpression } from './utils.js'; export const create = (): LanguageServicePlugin => { const htmlPlugin = createHtmlService({ @@ -44,9 +44,18 @@ export const create = (): LanguageServicePlugin => { const sourceScript = decoded && context.language.scripts.get(decoded[0]); const root = sourceScript?.generated?.root; if (!(root instanceof AstroVirtualCode)) return; + const offset = document.offsetAt(position); // Don't return completions if the current node is a component - if (isInComponentStartTag(root.htmlDocument, document.offsetAt(position))) { + if (isInComponentStartTag(root.htmlDocument, offset)) { + return null; + } + + const currentNode = root.htmlDocument.findNodeAt(offset); + const sourceText = root.snapshot.getText(0, root.snapshot.getLength()); + + // Let the TypeScript service handle `{...}` expressions in HTML attributes. + if (isInsideExpression(sourceText, currentNode.start, offset)) { return null; } diff --git a/packages/language-tools/language-server/test/typescript/completions.test.ts b/packages/language-tools/language-server/test/typescript/completions.test.ts index bfd45d0f2fd7..de019eaca471 100644 --- a/packages/language-tools/language-server/test/typescript/completions.test.ts +++ b/packages/language-tools/language-server/test/typescript/completions.test.ts @@ -122,4 +122,29 @@ describe('TypeScript - Completions', async () => { const allLabels = completions?.items.map((item) => item.label); assert.ok(allLabels.includes('alert')); }); + + it('Can get completions inside HTML attribute expressions', async () => { + const documents = [ + { + content: '---\nconst something = "Hello";\n---\n\nClick here', + position: Position.create(4, 13), + }, + { + content: '---\nconst something = "Hello";\n---\n\n{some}', + position: Position.create(4, 12), + }, + ]; + + for (const { content, position } of documents) { + const document = await languageServer.openFakeDocument(content, 'astro'); + const completions = await languageServer.handle.sendCompletionRequest( + document.uri, + position, + ); + + const allLabels = completions?.items.map((item) => item.label); + assert.ok(allLabels); + assert.ok(allLabels.includes('something')); + } + }); }); From 85060cda7949dd65820d511b021ab294c0e2c009 Mon Sep 17 00:00:00 2001 From: Felmon Fekadu Date: Mon, 16 Mar 2026 14:46:24 +0000 Subject: [PATCH 9/9] [ci] format --- .../language-server/test/typescript/completions.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/language-tools/language-server/test/typescript/completions.test.ts b/packages/language-tools/language-server/test/typescript/completions.test.ts index de019eaca471..f4366fed5e7f 100644 --- a/packages/language-tools/language-server/test/typescript/completions.test.ts +++ b/packages/language-tools/language-server/test/typescript/completions.test.ts @@ -137,10 +137,7 @@ describe('TypeScript - Completions', async () => { for (const { content, position } of documents) { const document = await languageServer.openFakeDocument(content, 'astro'); - const completions = await languageServer.handle.sendCompletionRequest( - document.uri, - position, - ); + const completions = await languageServer.handle.sendCompletionRequest(document.uri, position); const allLabels = completions?.items.map((item) => item.label); assert.ok(allLabels);