From 145f5859eb560b4cb99f52d3598441707a4a17be Mon Sep 17 00:00:00 2001 From: Rafael Yasuhide Sudo Date: Thu, 12 Mar 2026 18:15:22 +0900 Subject: [PATCH 01/10] chore: change `baseBranch` of changeset from `origin/next` to `origin/main` (#15865) --- .changeset/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index 902828cfe8b7..be86265817aa 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "linked": [], "access": "public", - "baseBranch": "origin/next", + "baseBranch": "origin/main", "updateInternalDependencies": "patch", "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true From e0042f720274d8763907c1d429723192a71d6932 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 12 Mar 2026 08:05:00 -0400 Subject: [PATCH 02/10] fix(astro): server islands not working in prerendered pages with server output (#15767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(astro): server islands not working in prerendered pages with server output (#15753) * Remove unnecessary fs import and dist cleanup from test * fix: patch server island manifest across build environments When server islands (server:defer) are only used in prerendered pages, the SSR build environment never discovers them — it leaves empty map placeholders in the manifest. The prerender build discovers the islands later but the SSR output is already finalized. Fix by deferring placeholder replacement during build: renderChunk leaves placeholders intact when no islands are known yet, renderChunk saves resolved filenames when processing them in the prerender env, and generateBundle patches the SSR manifest chunk in-memory. A post-build hook also patches the on-disk SSR output and copies island component chunks from the prerender directory to the server output. * refactor server-island post-build patching into plugin hook * refactor: rename deferred server entries environment * fix: remove extra server-islands build environment * chore: resolve lint ci unused exports and directives * refactor: simplify server island map source generation * refactor: share prerender output directory helper * Simplify changeset * chore: restore benchmark template eslint directive * test: allow either quote style in server-islands assertion --- .changeset/fix-server-islands-prerender.md | 5 + packages/astro/src/core/build/index.ts | 40 +++- packages/astro/src/core/build/static-build.ts | 44 +++- .../vite-plugin-server-islands.ts | 219 ++++++++++++++---- packages/astro/src/prerender/utils.ts | 7 + packages/astro/templates/env.mjs | 1 - .../test/units/build/server-islands.test.js | 122 ++++++++++ .../astro/test/units/build/test-helpers.js | 50 ++++ 8 files changed, 428 insertions(+), 60 deletions(-) create mode 100644 .changeset/fix-server-islands-prerender.md create mode 100644 packages/astro/test/units/build/server-islands.test.js diff --git a/.changeset/fix-server-islands-prerender.md b/.changeset/fix-server-islands-prerender.md new file mode 100644 index 000000000000..fe2c1142fc1d --- /dev/null +++ b/.changeset/fix-server-islands-prerender.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes server islands (`server:defer`) not working when only used in prerendered pages with `output: 'server'`. diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 54383a76a41f..dce2b3ec6e77 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -92,9 +92,19 @@ interface AstroBuilderOptions extends BuildOptions { logger: Logger; mode: string; runtimeMode: RuntimeMode; + /** + * Provide a pre-built routes list to skip filesystem route scanning. + * Useful for testing builds with in-memory virtual modules. + */ + routesList?: RoutesList; + /** + * Whether to run `syncInternal` during setup. Defaults to true. + * Set to false for in-memory builds that don't need type generation. + */ + sync?: boolean; } -class AstroBuilder { +export class AstroBuilder { private settings: AstroSettings; private logger: Logger; private mode: string; @@ -103,6 +113,7 @@ class AstroBuilder { private routesList: RoutesList; private timer: Record; private teardownCompiler: boolean; + private sync: boolean; constructor(settings: AstroSettings, options: AstroBuilderOptions) { this.mode = options.mode; @@ -110,10 +121,11 @@ class AstroBuilder { this.settings = settings; this.logger = options.logger; this.teardownCompiler = options.teardownCompiler ?? true; + this.sync = options.sync ?? true; this.origin = settings.config.site ? new URL(settings.config.site).origin : `http://localhost:${settings.config.server.port}`; - this.routesList = { routes: [] }; + this.routesList = options.routesList ?? { routes: [] }; this.timer = {}; } @@ -128,7 +140,11 @@ class AstroBuilder { logger: logger, }); this.settings.buildOutput = getPrerenderDefault(this.settings.config) ? 'static' : 'server'; - this.routesList = await createRoutesList({ settings: this.settings }, this.logger); + + // Skip filesystem route scanning if routesList was pre-populated (e.g. in-memory builds) + if (this.routesList.routes.length === 0) { + this.routesList = await createRoutesList({ settings: this.settings }, this.logger); + } await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' }); @@ -155,14 +171,16 @@ class AstroBuilder { }, ); - const { syncInternal } = await import('../sync/index.js'); - await syncInternal({ - mode: this.mode, - settings: this.settings, - logger, - fs, - command: 'build', - }); + if (this.sync) { + const { syncInternal } = await import('../sync/index.js'); + await syncInternal({ + mode: this.mode, + settings: this.settings, + logger, + fs, + command: 'build', + }); + } return { viteConfig }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 2e70352fd791..ed20c6aa0a09 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -10,7 +10,11 @@ import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { runHookBuildSetup } from '../../integrations/hooks.js'; import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../manifest/serialized.js'; -import { getClientOutputDirectory, getServerOutputDirectory } from '../../prerender/utils.js'; +import { + getClientOutputDirectory, + getPrerenderOutputDirectory, + getServerOutputDirectory, +} from '../../prerender/utils.js'; import type { RouteData } from '../../types/public/internal.js'; import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; @@ -33,6 +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'; const PRERENDER_ENTRY_FILENAME_PREFIX = 'prerender-entry'; @@ -47,6 +52,11 @@ export interface ExtractedChunk { prerender: boolean; } +type BuildPostHook = (params: { + chunks: ExtractedChunk[]; + mutate: (fileName: string, code: string, prerender: boolean) => void; +}) => void | Promise; + /** * Extracts only the chunks that need post-build injection from RollupOutput. * This allows releasing the full RollupOutput to reduce memory usage. @@ -63,8 +73,9 @@ function extractRelevantChunks( const needsContentInjection = chunk.code.includes(LINKS_PLACEHOLDER); const needsManifestInjection = chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID); + const needsServerIslandInjection = chunk.code.includes(serverIslandPlaceholderMap); - if (needsContentInjection || needsManifestInjection) { + if (needsContentInjection || needsManifestInjection || needsServerIslandInjection) { extracted.push({ fileName: chunk.fileName, code: chunk.code, @@ -159,6 +170,7 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter const flatPlugins = buildPlugins.flat().filter(Boolean); const plugins = [...flatPlugins, ...(viteConfig.plugins || [])]; let currentRollupInput: InputOption | undefined = undefined; + let buildPostHooks: BuildPostHook[] = []; plugins.push({ name: 'astro:resolve-input', // When the rollup input is safe to update, we normalize it to always be an object @@ -187,10 +199,15 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter order: 'post', async handler() { // Inject manifest and content placeholders into extracted chunks - await runManifestInjection(opts, internals, internals.extractedChunks ?? []); + await runManifestInjection( + opts, + internals, + internals.extractedChunks ?? [], + buildPostHooks, + ); // Generation and cleanup - const prerenderOutputDir = new URL('./.prerender/', getServerOutputDirectory(settings)); + const prerenderOutputDir = getPrerenderOutputDirectory(settings); // TODO: The `static` and `server` branches below are nearly identical now. // Consider refactoring to remove the else-if and unify the logic. @@ -335,6 +352,14 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter const prerenderChunks = extractRelevantChunks(prerenderOutputs, true); prerenderOutput = undefined as any; + const ssrPlugins = + builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]?.config.plugins ?? []; + buildPostHooks = ssrPlugins + .map((plugin) => + typeof plugin.api?.buildPostHook === 'function' ? plugin.api.buildPostHook : undefined, + ) + .filter(Boolean) as BuildPostHook[]; + // Build client environment // We must discover client inputs after SSR build because hydration/client-only directives // are only detected during SSR. We mutate the config here since the builder was already created @@ -364,7 +389,7 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter [ASTRO_VITE_ENVIRONMENT_NAMES.prerender]: { build: { emitAssets: true, - outDir: fileURLToPath(new URL('./.prerender/', getServerOutputDirectory(settings))), + outDir: fileURLToPath(getPrerenderOutputDirectory(settings)), rollupOptions: { // Only skip the default prerender entrypoint if an adapter with `entrypointResolution: 'self'` is used // AND provides a custom prerenderer. Otherwise, use the default. @@ -470,6 +495,7 @@ async function runManifestInjection( opts: StaticBuildOptions, internals: BuildInternals, chunks: ExtractedChunk[], + buildPostHooks: BuildPostHook[], ) { const mutations = new Map(); @@ -484,6 +510,11 @@ async function runManifestInjection( internals, { chunks, mutate }, ); + + for (const buildPostHook of buildPostHooks) { + await buildPostHook({ chunks, mutate }); + } + await writeMutatedChunks(opts, mutations); } @@ -498,14 +529,13 @@ async function writeMutatedChunks( ) { const { settings } = opts; const config = settings.config; - const serverOutputDir = getServerOutputDirectory(settings); for (const [fileName, mutation] of mutations) { let root: URL; if (mutation.prerender) { // Write to prerender directory - root = new URL('./.prerender/', serverOutputDir); + root = getPrerenderOutputDirectory(settings); } else if (settings.buildOutput === 'server') { root = config.build.server; } else { 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 421e9160b143..2f51e7c20823 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,22 +1,54 @@ -import MagicString from 'magic-string'; +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'; export const SERVER_ISLAND_MANIFEST = 'virtual:astro:server-island-manifest'; const RESOLVED_SERVER_ISLAND_MANIFEST = '\0' + SERVER_ISLAND_MANIFEST; -const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; +export const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; const serverIslandPlaceholderNameMap = "'$$server-islands-name-map$$'"; +function createServerIslandImportMapSource( + entries: Iterable<[string, string]>, + toImportPath: (fileName: string) => string, +) { + const mappings = Array.from(entries, ([islandName, fileName]) => { + const importPath = toImportPath(fileName); + return `\t[${JSON.stringify(islandName)}, () => import(${JSON.stringify(importPath)})],`; + }); + + return `new Map([\n${mappings.join('\n')}\n])`; +} + +function createNameMapSource(entries: Iterable<[string, string]>) { + return `new Map(${JSON.stringify(Array.from(entries), null, 2)})`; +} + export function vitePluginServerIslands({ settings }: AstroPluginOptions): VitePlugin { let command: ConfigEnv['command'] = 'serve'; let ssrEnvironment: DevEnvironment | null = null; const referenceIdMap = new Map(); - const serverIslandMap = new Map(); - const serverIslandNameMap = 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) + const serverIslandMap = new Map(); + const serverIslandNameMap = 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; + return { name: 'astro:server-islands', enforce: 'post', @@ -78,13 +110,12 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP name += idx++; } - // Append the name map, for prod + // Track the island component for later build/dev use serverIslandNameMap.set(comp.resolvedPath, name); serverIslandMap.set(name, comp.resolvedPath); - // Build mode if (command === 'build') { - let referenceId = this.emitFile({ + const referenceId = this.emitFile({ type: 'chunk', id: comp.specifier, importer: id, @@ -115,16 +146,16 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP } if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0) { - let mapSource = 'new Map([\n\t'; - for (let [name, path] of serverIslandMap) { - mapSource += `\n\t['${name}', () => import('${path}')],`; - } - mapSource += ']);'; + const mapSource = createServerIslandImportMapSource( + serverIslandMap, + (fileName) => fileName, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); return { code: ` export const serverIslandMap = ${mapSource}; - \n\nexport const serverIslandNameMap = new Map(${JSON.stringify(Array.from(serverIslandNameMap.entries()), null, 2)}); + \n\nexport const serverIslandNameMap = ${nameMapSource}; `, }; } @@ -134,42 +165,148 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP renderChunk(code, chunk) { if (code.includes(serverIslandPlaceholderMap)) { - if (referenceIdMap.size === 0) { - // If there's no reference, we can fast-path to an empty map replacement - // without sourcemaps as it doesn't shift rows + 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; + } + + 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( + mapEntries, + (fileName) => `${dots}/${fileName}`, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); + return { code: code - .replace(serverIslandPlaceholderMap, 'new Map();') - .replace(serverIslandPlaceholderNameMap, 'new Map()'), + .replace(serverIslandPlaceholderMap, mapSource) + .replace(serverIslandPlaceholderNameMap, nameMapSource), map: null, }; } - // The server island modules are in chunks/ - // This checks if this module is also in chunks/ and if so - // make the import like import('../chunks/name.mjs') - // TODO we could possibly refactor this to not need to emit separate chunks. - const isRelativeChunk = !chunk.isEntry; - const dots = isRelativeChunk ? '..' : '.'; - let mapSource = 'new Map(['; - let nameMapSource = 'new Map('; - for (let [resolvedPath, referenceId] of referenceIdMap) { - const fileName = this.getFileName(referenceId); - const islandName = serverIslandNameMap.get(resolvedPath)!; - mapSource += `\n\t['${islandName}', () => import('${dots}/${fileName}')],`; - } - nameMapSource += `${JSON.stringify(Array.from(serverIslandNameMap.entries()), null, 2)}`; - mapSource += '\n])'; - nameMapSource += '\n)'; - referenceIdMap.clear(); - - const ms = new MagicString(code); - ms.replace(serverIslandPlaceholderMap, mapSource); - ms.replace(serverIslandPlaceholderNameMap, nameMapSource); + // Dev mode: fast-path to empty map replacement return { - code: ms.toString(), - map: ms.generateMap({ hires: 'boundary' }), + code: code + .replace(serverIslandPlaceholderMap, 'new Map();') + .replace(serverIslandPlaceholderNameMap, 'new Map()'), + 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); + } + }, + }, }; } diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index eedbdd47543a..351e40706331 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -15,6 +15,13 @@ export function getServerOutputDirectory(settings: AstroSettings): URL { : getOutDirWithinCwd(settings.config.outDir); } +/** + * Returns the output directory used by the prerender environment. + */ +export function getPrerenderOutputDirectory(settings: AstroSettings): URL { + return new URL('./.prerender/', getServerOutputDirectory(settings)); +} + /** * Returns the correct output directory of the client build based on the configuration */ diff --git a/packages/astro/templates/env.mjs b/packages/astro/templates/env.mjs index 0681b96f6e0e..7546fff1208f 100644 --- a/packages/astro/templates/env.mjs +++ b/packages/astro/templates/env.mjs @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ // @ts-check import { schema } from 'virtual:astro:env/internal'; import { diff --git a/packages/astro/test/units/build/server-islands.test.js b/packages/astro/test/units/build/server-islands.test.js new file mode 100644 index 000000000000..56a9cfdd15d1 --- /dev/null +++ b/packages/astro/test/units/build/server-islands.test.js @@ -0,0 +1,122 @@ +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 { AstroBuilder } from '../../../dist/core/build/index.js'; +import { parseRoute } from '../../../dist/core/routing/parse-route.js'; +import { createBasicSettings, defaultLogger } from '../test-utils.js'; +import { virtualAstroModules } from './test-helpers.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('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); + + 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'), + }), + ], + }, + }); + + const routesList = { + routes: [ + parseRoute('index.astro', settings, { + component: 'src/pages/index.astro', + prerender: true, + }), + ], + }; + + // Inject the server island route — normally createRoutesList does this + 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( + !manifestContent.includes('$$server-islands-map$$'), + `Server island manifest should not include placeholders but got:\n${manifestContent}`, + ); + }); +}); diff --git a/packages/astro/test/units/build/test-helpers.js b/packages/astro/test/units/build/test-helpers.js index 582706e9cae2..2059d827e7b1 100644 --- a/packages/astro/test/units/build/test-helpers.js +++ b/packages/astro/test/units/build/test-helpers.js @@ -1,4 +1,5 @@ // @ts-check +import { fileURLToPath } from 'node:url'; /** * @param {object} options @@ -29,3 +30,52 @@ export function createSettings({ }, }; } + +/** + * A Vite plugin that provides in-memory .astro source files as virtual modules. + * This allows running a full Astro build without any files on disk. + * + * @param {URL} root - The project root URL + * @param {Record} files - Map of relative paths (e.g. 'src/pages/index.astro') to source content + */ +export function virtualAstroModules(root, files) { + const virtualFiles = new Map(); + for (const [relativePath, source] of Object.entries(files)) { + const absolute = fileURLToPath(new URL(relativePath, root)); + virtualFiles.set(absolute, source); + } + + return { + name: 'virtual-astro-modules', + enforce: 'pre', + resolveId: { + handler(id, importer) { + // Handle absolute paths (used by server island manifest dynamic imports) + if (virtualFiles.has(id)) { + return id; + } + // Handle bare paths like "/src/pages/index.astro" + if (id.startsWith('/')) { + const absolute = fileURLToPath(new URL('.' + id, root)); + if (virtualFiles.has(absolute)) { + return absolute; + } + } + // Handle relative imports from within virtual files + if (importer && virtualFiles.has(importer) && id.startsWith('.')) { + const resolved = fileURLToPath(new URL(id, 'file://' + importer)); + if (virtualFiles.has(resolved)) { + return resolved; + } + } + }, + }, + load: { + handler(id) { + if (virtualFiles.has(id)) { + return { code: virtualFiles.get(id) }; + } + }, + }, + }; +} From 920f10bb3a49da8355967df99c32c43cc9f53b46 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 12 Mar 2026 09:15:30 -0400 Subject: [PATCH 03/10] fix(astro): prebundle astro/toolbar for custom dev toolbar apps (#15870) --- .changeset/fix-dev-toolbar-optimize-deps.md | 5 +++++ packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-dev-toolbar-optimize-deps.md diff --git a/.changeset/fix-dev-toolbar-optimize-deps.md b/.changeset/fix-dev-toolbar-optimize-deps.md new file mode 100644 index 000000000000..04247b08995d --- /dev/null +++ b/.changeset/fix-dev-toolbar-optimize-deps.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Prebundle `astro/toolbar` in dev when custom dev toolbar apps are registered, preventing re-optimization reloads that can hide or break the toolbar. diff --git a/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts index ab46509751bc..aaac1d5fcce7 100644 --- a/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts +++ b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts @@ -16,7 +16,11 @@ export default function astroDevToolbar({ settings, logger }: AstroPluginOptions return { optimizeDeps: { // Optimize CJS dependencies used by the dev toolbar - include: ['astro > aria-query', 'astro > axobject-query'], + include: [ + 'astro > aria-query', + 'astro > axobject-query', + ...(settings.devToolbarApps.length > 0 ? ['astro/toolbar'] : []), + ], }, }; }, From 3b47b898ebc587251d2674e7fae2e6bcc86c7809 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:37:21 +0100 Subject: [PATCH 04/10] fix(cloudflare): Use passthrough in dev mode when using the `cloudflare` option (#15872) --- .changeset/sparkly-peas-mix.md | 5 ++++ .../cloudflare/src/utils/image-config.ts | 10 ++++++++ .../test/external-image-service.test.js | 22 ++++++++++++++++++ .../src/assets/placeholder.jpg | Bin 0 -> 38690 bytes .../src/pages/index.astro | 14 +++++++++++ 5 files changed, 51 insertions(+) create mode 100644 .changeset/sparkly-peas-mix.md create mode 100644 packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg create mode 100644 packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro diff --git a/.changeset/sparkly-peas-mix.md b/.changeset/sparkly-peas-mix.md new file mode 100644 index 000000000000..a50932f638be --- /dev/null +++ b/.changeset/sparkly-peas-mix.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes images not working in dev mode when using the `cloudflare` option diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index a86db46234f9..473bd6993718 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -65,6 +65,16 @@ export function setImageConfig( }; case 'cloudflare': + // The external Cloudflare image service generates `/cdn-cgi/image/...` URLs, + // which only work on Cloudflare's production edge network. In dev mode, + // fall back to passthrough so images render normally without transformation. + if (command === 'dev') { + return { + ...config, + service: passthroughImageService(), + endpoint: GENERIC_ENDPOINT, + }; + } return { ...config, service: { entrypoint: '@astrojs/cloudflare/image-service' }, diff --git a/packages/integrations/cloudflare/test/external-image-service.test.js b/packages/integrations/cloudflare/test/external-image-service.test.js index 6b22ebd54969..8662df507130 100644 --- a/packages/integrations/cloudflare/test/external-image-service.test.js +++ b/packages/integrations/cloudflare/test/external-image-service.test.js @@ -33,3 +33,25 @@ describe('ExternalImageService', () => { assert.equal(outFileToCheck.includes('cdn-cgi/image'), true); }); }); + +describe('ExternalImageService dev mode', () => { + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/external-image-service/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('does not generate /cdn-cgi/image/ URLs in dev mode', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.ok(!html.includes('/cdn-cgi/image/'), 'expected no cdn-cgi URL in dev mode'); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4fc88e293c2d70d1b193101df252bf45ca57760 GIT binary patch literal 38690 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 T|DXOxj{SQDa(JEk*X+Lm3zv-d literal 0 HcmV?d00001 diff --git a/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro new file mode 100644 index 000000000000..109e11fdf776 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +import { Image } from 'astro:assets'; +import placeholder from '../assets/placeholder.jpg'; +--- + + + + + External Image Service Test + + + test + + From ce0669d68115c5e2d00238f3e780a2af50f5be11 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 12 Mar 2026 13:39:21 +0000 Subject: [PATCH 05/10] fix(prefetch): optimise prefetch script (#15874) Co-authored-by: astrobot-houston --- .changeset/swift-terms-lose.md | 5 +++++ packages/astro/src/vite-plugin-environment/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/swift-terms-lose.md diff --git a/.changeset/swift-terms-lose.md b/.changeset/swift-terms-lose.md new file mode 100644 index 000000000000..7e47a712e74f --- /dev/null +++ b/.changeset/swift-terms-lose.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a warning when using `prefetchAll` diff --git a/packages/astro/src/vite-plugin-environment/index.ts b/packages/astro/src/vite-plugin-environment/index.ts index 32e8ece90946..d2002a5e6de0 100644 --- a/packages/astro/src/vite-plugin-environment/index.ts +++ b/packages/astro/src/vite-plugin-environment/index.ts @@ -90,7 +90,7 @@ export function vitePluginEnvironment({ // For the dev toolbar 'astro > html-escaper', ], - exclude: ['astro:*', 'virtual:astro:*'], + exclude: ['astro:*', 'virtual:astro:*', 'astro/virtual-modules/prefetch.js'], // Astro files can't be rendered on the client entries: [`${srcDirPattern}**/*.{jsx,tsx,vue,svelte,html}`], }; From 58f1d63cbcdd351d80cc65ceff4cb1a8d1aa1853 Mon Sep 17 00:00:00 2001 From: Rafael Yasuhide Sudo Date: Thu, 12 Mar 2026 22:41:50 +0900 Subject: [PATCH 06/10] fix: prevent route guard from blocking pages that share names with root directories (#15754) Co-authored-by: astrobot-houston --- .changeset/tricky-otters-go.md | 5 +++++ .../src/vite-plugin-astro-server/route-guard.ts | 16 +++++++++++----- .../fixtures/route-guard/src/pages/test.astro | 8 ++++++++ .../test/fixtures/route-guard/test-1/.gitkeep | 0 .../test/fixtures/route-guard/test/.gitkeep | 0 packages/astro/test/route-guard.test.js | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 .changeset/tricky-otters-go.md create mode 100644 packages/astro/test/fixtures/route-guard/src/pages/test.astro create mode 100644 packages/astro/test/fixtures/route-guard/test-1/.gitkeep create mode 100644 packages/astro/test/fixtures/route-guard/test/.gitkeep diff --git a/.changeset/tricky-otters-go.md b/.changeset/tricky-otters-go.md new file mode 100644 index 000000000000..923730522e83 --- /dev/null +++ b/.changeset/tricky-otters-go.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a bug where a directory at the project root sharing the same name as a page route would cause the dev server to return a 404 instead of serving the page. diff --git a/packages/astro/src/vite-plugin-astro-server/route-guard.ts b/packages/astro/src/vite-plugin-astro-server/route-guard.ts index 66ec8c77a9e3..9f921e2544c7 100644 --- a/packages/astro/src/vite-plugin-astro-server/route-guard.ts +++ b/packages/astro/src/vite-plugin-astro-server/route-guard.ts @@ -68,12 +68,18 @@ export function routeGuardMiddleware(settings: AstroSettings): vite.Connect.Next return next(); } - // Check if the file exists at project root (outside srcDir/publicDir) + // Check if a file exists at project root (outside srcDir/publicDir) + // Only block files, not directories — directories may share names with valid page routes const rootFilePath = new URL('.' + pathname, config.root); - if (fs.existsSync(rootFilePath)) { - // File exists at root but not in srcDir or publicDir - block it - const html = notFoundTemplate(pathname); - return writeHtmlResponse(res, 404, html); + try { + const stat = fs.statSync(rootFilePath); + if (stat.isFile()) { + // File exists at root but not in srcDir or publicDir - block it + const html = notFoundTemplate(pathname); + return writeHtmlResponse(res, 404, html); + } + } catch { + // Path doesn't exist, continue to next middleware } // File doesn't exist anywhere, let other middleware handle it diff --git a/packages/astro/test/fixtures/route-guard/src/pages/test.astro b/packages/astro/test/fixtures/route-guard/src/pages/test.astro new file mode 100644 index 000000000000..2fe331eb897a --- /dev/null +++ b/packages/astro/test/fixtures/route-guard/src/pages/test.astro @@ -0,0 +1,8 @@ +--- +--- + +Test Page + +

Test Page

+ + diff --git a/packages/astro/test/fixtures/route-guard/test-1/.gitkeep b/packages/astro/test/fixtures/route-guard/test-1/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/astro/test/fixtures/route-guard/test/.gitkeep b/packages/astro/test/fixtures/route-guard/test/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/astro/test/route-guard.test.js b/packages/astro/test/route-guard.test.js index 7f6303fb60d2..6c2bd10d2352 100644 --- a/packages/astro/test/route-guard.test.js +++ b/packages/astro/test/route-guard.test.js @@ -63,6 +63,22 @@ describe('Route Guard - Dev Server', () => { }); }); + describe('Directories at project root should not conflict with routes', () => { + const browserHeaders = { headers: { Accept: 'text/html' } }; + + it('200 when loading /test (route exists and directory exists at project root)', async () => { + const response = await fixture.fetch('/test', browserHeaders); + assert.equal(response.status, 200); + const html = await response.text(); + assert.match(html, /Test Page/); + }); + + it('404 when loading /test-1 (no route exists and directory exists at project root)', async () => { + const response = await fixture.fetch('/test-1', browserHeaders); + assert.equal(response.status, 404); + }); + }); + describe('Nonexistent files should 404 normally', () => { it('404 when loading /nonexistent.md (file does not exist)', async () => { const response = await fixture.fetch('/nonexistent.md'); From bb2b8f5cd3c9f3140b4bb0fb5a1d4c62b41883b8 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 12 Mar 2026 13:50:49 +0000 Subject: [PATCH 07/10] fix: correctly exclude entrypoint via environments (#15868) --- .changeset/wise-turkeys-tan.md | 6 ++++++ packages/integrations/netlify/src/index.ts | 3 --- .../integrations/netlify/src/vite-plugin-config.ts | 11 +++++++++++ packages/integrations/node/package.json | 3 +++ packages/integrations/node/src/index.ts | 4 +--- packages/integrations/node/src/vite-plugin-config.ts | 11 +++++++++++ 6 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 .changeset/wise-turkeys-tan.md diff --git a/.changeset/wise-turkeys-tan.md b/.changeset/wise-turkeys-tan.md new file mode 100644 index 000000000000..dfc33b50020b --- /dev/null +++ b/.changeset/wise-turkeys-tan.md @@ -0,0 +1,6 @@ +--- +'@astrojs/netlify': patch +'@astrojs/node': patch +--- + +Fixes an issue where the adapter would cause a series of warnings during the build. diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index d1397f6f817e..d0541eac7cd5 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -664,9 +664,6 @@ export default function netlifyIntegration( cacheOnDemandPages: !!integrationConfig?.cacheOnDemandPages, }), ], - ssr: { - noExternal: ['@astrojs/netlify'], - }, server: { watch: { ignored: [fileURLToPath(new URL('./.netlify/**', rootDir))], diff --git a/packages/integrations/netlify/src/vite-plugin-config.ts b/packages/integrations/netlify/src/vite-plugin-config.ts index 23a756f29c42..6737422c0bb1 100644 --- a/packages/integrations/netlify/src/vite-plugin-config.ts +++ b/packages/integrations/netlify/src/vite-plugin-config.ts @@ -8,9 +8,20 @@ export interface Config { cacheOnDemandPages: boolean; } +const SERVER_ENVIRONMENTS = ['ssr', 'prerender', 'astro']; + export function createConfigPlugin(config: Config): PluginOption { return { name: VIRTUAL_CONFIG_ID, + configEnvironment(environmentName) { + if (SERVER_ENVIRONMENTS.includes(environmentName)) { + return { + resolve: { + noExternal: ['@astrojs/netlify'], + }, + }; + } + }, resolveId: { filter: { id: new RegExp(`^${VIRTUAL_CONFIG_ID}$`), diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 4591d81e5585..84126b923722 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -54,6 +54,9 @@ "@fastify/static": "^9.0.0", "node-mocks-http": "^1.17.2" }, + "astro": { + "external": true + }, "publishConfig": { "provenance": true } diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 7a2e4c767afd..349ece144668 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -67,9 +67,6 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr }, session, vite: { - ssr: { - noExternal: ['@astrojs/node'], - }, plugins: [ createConfigPlugin({ ...userOptions, @@ -79,6 +76,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr port: _config.server.port, staticHeaders: userOptions.staticHeaders ?? false, bodySizeLimit: userOptions.bodySizeLimit ?? 1024 * 1024 * 1024, + experimentalDisableStreaming: userOptions.experimentalDisableStreaming ?? false, }), ], }, diff --git a/packages/integrations/node/src/vite-plugin-config.ts b/packages/integrations/node/src/vite-plugin-config.ts index 76a025e9168e..971fe4127b6b 100644 --- a/packages/integrations/node/src/vite-plugin-config.ts +++ b/packages/integrations/node/src/vite-plugin-config.ts @@ -4,11 +4,22 @@ import type { Options } from './types.js'; const VIRTUAL_CONFIG_ID = 'virtual:astro-node:config'; const RESOLVED_VIRTUAL_CONFIG_ID = '\0' + VIRTUAL_CONFIG_ID; +const SERVER_ENVIRONMENTS = ['ssr', 'prerender', 'astro']; + export function createConfigPlugin( config: Options, ): NonNullable[number] { return { name: VIRTUAL_CONFIG_ID, + configEnvironment(environmentName) { + if (SERVER_ENVIRONMENTS.includes(environmentName)) { + return { + resolve: { + noExternal: ['@astrojs/node'], + }, + }; + } + }, resolveId: { filter: { id: new RegExp(`^${VIRTUAL_CONFIG_ID}$`), From 35841ed273581a567cd726bb2d14d2ed3886bed0 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 12 Mar 2026 09:52:36 -0400 Subject: [PATCH 08/10] fix(astro): invalidate dev CSS map when routes change (#15873) Regenerate the dev route-to-CSS map after route rebuilds so newly created pages in dev include layout-imported styles without requiring a server restart. --- .changeset/new-routes-dev-css.md | 5 +++++ packages/astro/src/vite-plugin-routes/index.ts | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/new-routes-dev-css.md diff --git a/.changeset/new-routes-dev-css.md b/.changeset/new-routes-dev-css.md new file mode 100644 index 000000000000..46b7cfbbe188 --- /dev/null +++ b/.changeset/new-routes-dev-css.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix a dev server bug where newly created pages could miss layout-imported CSS until restart. diff --git a/packages/astro/src/vite-plugin-routes/index.ts b/packages/astro/src/vite-plugin-routes/index.ts index b3b260951b82..039f492ba94b 100644 --- a/packages/astro/src/vite-plugin-routes/index.ts +++ b/packages/astro/src/vite-plugin-routes/index.ts @@ -16,6 +16,7 @@ import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { isAstroServerEnvironment } from '../environments.js'; +import { RESOLVED_MODULE_DEV_CSS_ALL } from '../vite-plugin-css/const.js'; import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; type Payload = { @@ -125,6 +126,12 @@ export default async function astroPluginRoutes({ if (!virtualMod) continue; environment.moduleGraph.invalidateModule(virtualMod); + + const cssMod = environment.moduleGraph.getModuleById(RESOLVED_MODULE_DEV_CSS_ALL); + if (cssMod) { + environment.moduleGraph.invalidateModule(cssMod); + } + // Signal that routes have changed so running apps can update // NOTE: Consider adding debouncing here if rapid file changes cause performance issues environment.hot.send('astro:routes-updated', {}); From 76b3a5e4bb1e9f2855d4169602295d601d7e7436 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 12 Mar 2026 09:52:58 -0400 Subject: [PATCH 09/10] fix(astro): correct noExternal config hint for Vite 7 (#15869) --- .changeset/wise-cats-check.md | 5 +++++ packages/astro/src/core/errors/dev/utils.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/wise-cats-check.md diff --git a/.changeset/wise-cats-check.md b/.changeset/wise-cats-check.md new file mode 100644 index 000000000000..560fddeb3f07 --- /dev/null +++ b/.changeset/wise-cats-check.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Update the unknown file extension error hint to recommend `vite.resolve.noExternal`, which is the correct Vite 7 config key. diff --git a/packages/astro/src/core/errors/dev/utils.ts b/packages/astro/src/core/errors/dev/utils.ts index c55a639c130e..61ad0e371d06 100644 --- a/packages/astro/src/core/errors/dev/utils.ts +++ b/packages/astro/src/core/errors/dev/utils.ts @@ -140,7 +140,7 @@ function generateHint(err: ErrorWithMetadata): string | undefined { const commonBrowserAPIs = ['document', 'window']; if (/Unknown file extension "\.(?:jsx|vue|svelte|astro|css)" for /.test(err.message)) { - return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.'; + return 'You likely need to add this package to `vite.resolve.noExternal` in your astro config file.'; } else if (commonBrowserAPIs.some((api) => err.toString().includes(api))) { const hint = `Browser APIs are not available on the server. From f47ac5352dcb36daa64ec12b7d4ac193045d10e3 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 12 Mar 2026 14:26:12 +0000 Subject: [PATCH 10/10] fix(i18n): regression of router refactor (#15876) Co-authored-by: astrobot-houston --- .changeset/fix-i18n-redirect-double-slash.md | 5 ++ packages/astro/src/i18n/router.ts | 4 +- packages/astro/test/units/i18n/router.test.js | 79 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-i18n-redirect-double-slash.md diff --git a/.changeset/fix-i18n-redirect-double-slash.md b/.changeset/fix-i18n-redirect-double-slash.md new file mode 100644 index 000000000000..170a821ea40f --- /dev/null +++ b/.changeset/fix-i18n-redirect-double-slash.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `redirectToDefaultLocale` producing a protocol-relative URL (`//locale`) instead of an absolute path (`/locale`) when `base` is `'/'`. diff --git a/packages/astro/src/i18n/router.ts b/packages/astro/src/i18n/router.ts index 612770cf1b3f..2aa26698e454 100644 --- a/packages/astro/src/i18n/router.ts +++ b/packages/astro/src/i18n/router.ts @@ -151,9 +151,11 @@ export class I18nRouter { if (isRoot) { // Redirect root to default locale + // When base is '/', avoid producing '//locale' (a protocol-relative URL) + const basePrefix = this.#base === '/' ? '' : this.#base; return { type: 'redirect', - location: `${this.#base}/${this.#defaultLocale}`, + location: `${basePrefix}/${this.#defaultLocale}`, }; } diff --git a/packages/astro/test/units/i18n/router.test.js b/packages/astro/test/units/i18n/router.test.js index bdcafaa2ac35..f95743f5c666 100644 --- a/packages/astro/test/units/i18n/router.test.js +++ b/packages/astro/test/units/i18n/router.test.js @@ -88,6 +88,45 @@ describe('I18nRouter', () => { assert.equal(result.type, 'notFound'); }); }); + + describe('with base "/" (root base path)', () => { + let routerWithSlashBase; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }); + routerWithSlashBase = new I18nRouter(config); + }); + + it('redirects root to /defaultLocale, not //defaultLocale (#15844)', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal(result.location, '/en'); + }); + + it('continues for paths with valid locale prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result = routerWithSlashBase.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('returns 404 for paths without locale prefix', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + }); }); describe('strategy: pathname-prefix-other-locales', () => { @@ -211,6 +250,22 @@ describe('I18nRouter', () => { assert.equal(result.type, 'continue'); }); + + it('continues for root path when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'pathname-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }), + ); + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'continue'); + }); }); describe('strategy: domains-prefix-always', () => { @@ -263,6 +318,30 @@ describe('I18nRouter', () => { assert.equal(result.type, 'notFound'); }); + + it('redirects root to /locale, not //locale, when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'domains-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + }, + }), + ); + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal(result.location, '/en'); + }); }); describe('strategy: domains-prefix-other-locales', () => {