Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cloudflare-prebundle-server-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Prevents warnings in the Cloudflare adapter about optimizing the `@astrojs/cloudflare/entrypoints/server` module in dev.
7 changes: 7 additions & 0 deletions .changeset/cuddly-games-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Refactors path alias resolution to use Vite's native `tsconfigPaths` option

This is an internal change with no expected impact on user projects. Astro now defers tsconfig and jsconfig `paths` alias resolution to Vite, keeping a small fallback for a few CSS cases Vite does not yet handle.
5 changes: 5 additions & 0 deletions .changeset/every-breads-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes an issue where the ClientRouter wipes head elements after page transitions if the `<head>` contains a `server:defer` component.
5 changes: 5 additions & 0 deletions .changeset/proud-pugs-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a bug where `<style>` tags from components such as a content collection's `Content` could be silently dropped from the output when an `await` appeared before the component in an `.astro` file's markup.
5 changes: 5 additions & 0 deletions .changeset/spawn-background-dev-monorepo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes the background dev server failing to start when `astro` is hoisted outside the project's `node_modules` (for example bun workspaces). The background process is now spawned from Astro's own resolved location instead of a path assumed under the project root.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<link rel="stylesheet" href="."/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
import {ClientRouter} from 'astro:transitions';
import ServerIsland from '../components/ServerIsland.astro';
---
<head>
<link rel="stylesheet" href="before"/>
<ClientRouter />
<ServerIsland server:defer />
<link rel="stylesheet" href="after"/>
</head>
<body>
<a id="deferred" href="/deferred">Click</a>
</body>
17 changes: 17 additions & 0 deletions packages/astro/e2e/view-transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1868,4 +1868,21 @@ test.describe('View Transitions', () => {
page.locator('[data-vite-dev-id*="VueCounter.vue?vue&type=style"][data-marker="this"]'),
).toHaveCount(1);
});

test('no children get lost if there is a `server:defer` component in the head', async ({
page,
astro,
}) => {
await page.goto(astro.resolveUrl('/deferred'));
await expect(page.locator('head link[rel="stylesheet"][href="."]')).toHaveCount(1);
await expect(page.locator('head link[rel="stylesheet"][href="after"]')).toHaveCount(1);
await expect(page.locator('head link[rel="stylesheet"][href="before"]')).toHaveCount(1);

await page.click('#deferred');
await page.waitForLoadState('networkidle');

await expect(page.locator('head link[rel="stylesheet"][href="."]')).toHaveCount(1);
await expect(page.locator('head link[rel="stylesheet"][href="after"]')).toHaveCount(1);
await expect(page.locator('head link[rel="stylesheet"][href="before"]')).toHaveCount(1);
});
});
11 changes: 9 additions & 2 deletions packages/astro/src/cli/dev/background.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, openSync } from 'node:fs';
import { resolve } from 'node:path';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AstroLogger } from '../../core/logger/core.js';
import type { Flags } from '../flags.js';
Expand All @@ -15,6 +16,8 @@ import {
} from '../../core/dev/lockfile.js';
import { resolveRoot } from '../../core/config/config.js';

const require = createRequire(import.meta.url);

export interface BackgroundResult {
pid: number;
url: string;
Expand Down Expand Up @@ -120,7 +123,11 @@ export async function background({
// On Windows, .bin shims are .cmd batch files that cannot be spawned without
// a shell, so we avoid the shim entirely for cross-platform compatibility.
const rootPath = fileURLToPath(root);
const astroBin = resolve(rootPath, 'node_modules', 'astro', 'bin', 'astro.mjs');
// Resolve astro's entry from its own package location rather than the project
// root: in a hoisted monorepo (e.g. bun workspaces) `astro` is deduped to the
// workspace-root node_modules, so a project-relative path does not exist.
// `astro/bin/astro.mjs` is not exported, so resolve via the package manifest.
const astroBin = resolve(dirname(require.resolve('astro/package.json')), 'bin', 'astro.mjs');

// Spawn the dev server as a detached child process
const child = spawn(process.execPath, [astroBin, ...args], {
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/build/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StaticBuildOptions } from '../types.js';
import { pluginAnalyzer } from './plugin-analyzer.js';
import { pluginComponentEntry } from './plugin-component-entry.js';
import { pluginCSS } from './plugin-css.js';
import { pluginCssTargetLowering } from './plugin-css-target-lowering.js';
import { pluginInternals } from './plugin-internals.js';
import { pluginMiddleware } from './plugin-middleware.js';
import { pluginPrerender } from './plugin-prerender.js';
Expand All @@ -25,6 +26,7 @@ export function getAllBuildPlugins(
pluginInternals(options, internals),
pluginMiddleware(options, internals),
vitePluginActionsBuild(options, internals),
pluginCssTargetLowering(),
...pluginCSS(options, internals),
astroHeadBuildPlugin(internals),
pluginPrerender(options, internals),
Expand Down
124 changes: 124 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-css-target-lowering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { createRequire } from 'node:module';
import type { Plugin as VitePlugin, ResolvedConfig } from 'vite';

/**
* Browser name mapping from esbuild/Vite target format to lightningcss target format.
* Matches Vite's internal `map` object in `convertTargets`.
*/
const BROWSER_MAP: Record<string, string | false> = {
chrome: 'chrome',
edge: 'edge',
firefox: 'firefox',
hermes: false,
ie: 'ie',
ios: 'ios_saf',
node: false,
opera: 'opera',
rhino: false,
safari: 'safari',
};

const VERSION_RE = /\d/;

/**
* Converts esbuild/Vite target strings (e.g. `["safari15", "chrome100"]`) to
* lightningcss target format (e.g. `{ safari: 983040, chrome: 6553600 }`).
*
* This replicates Vite's internal `convertTargets` function, which is not exported.
*/
function convertTargets(esbuildTarget: string | string[] | undefined): Record<string, number> {
if (!esbuildTarget) return {};
const targets: Record<string, number> = {};
const entries = Array.isArray(esbuildTarget) ? esbuildTarget : [esbuildTarget];
for (const entry of entries) {
if (entry === 'esnext') continue;
const index = entry.search(VERSION_RE);
if (index >= 0) {
const browserName = entry.slice(0, index);
const browser = BROWSER_MAP[browserName];
if (browser === false) continue;
if (browser) {
const [major, minor = 0] = entry
.slice(index)
.split('.')
.map((v) => Number.parseInt(v, 10));
if (!isNaN(major) && !isNaN(minor)) {
const version = (major << 16) | (minor << 8);
if (!targets[browser] || version < targets[browser]) {
targets[browser] = version;
}
}
}
}
}
return targets;
}

/**
* Vite plugin that applies CSS target lowering without minification.
*
* Vite's `finalizeCss` only applies CSS target lowering (via lightningcss) when
* `config.build.cssMinify` is truthy, because target lowering and minification
* are coupled in `minifyCSS`. When users set `minify: false`, Astro forces
* `cssMinify: false`, which disables target lowering for all CSS.
*
* This plugin fills that gap: when `cssMinify` is falsy and a CSS target is
* configured, it runs lightningcss with `minify: false` on all CSS assets in
* `generateBundle`, applying target lowering without minification.
*/
export function pluginCssTargetLowering(): VitePlugin {
let resolvedConfig: ResolvedConfig;

return {
name: 'astro:css-target-lowering',
enforce: 'post',

configResolved(config) {
resolvedConfig = config;
},

async generateBundle(_outputOptions, bundle) {
// Only apply when cssMinify is disabled and a CSS target is configured
if (resolvedConfig.build.cssMinify) return;
const cssTarget = resolvedConfig.build.cssTarget;
if (!cssTarget) return;

const targets = convertTargets(cssTarget);
if (Object.keys(targets).length === 0) return;

// Resolve lightningcss from Vite's dependencies (it's not a direct Astro dep)
let lcssTransform: (opts: Record<string, unknown>) => {
code: Uint8Array;
warnings: unknown[];
};
try {
const requireFromVite = createRequire(import.meta.resolve('vite'));
lcssTransform = (requireFromVite('lightningcss') as { transform: typeof lcssTransform })
.transform;
} catch {
// If lightningcss is not available, skip silently
return;
}

for (const [, asset] of Object.entries(bundle)) {
if (asset.type !== 'asset') continue;
if (!asset.fileName.endsWith('.css')) continue;
if (typeof asset.source !== 'string') continue;

try {
const result = lcssTransform({
...resolvedConfig.css?.lightningcss,
targets,
cssModules: undefined,
filename: asset.fileName,
code: Buffer.from(asset.source),
minify: false,
});
asset.source = new TextDecoder().decode(result.code);
} catch {
// If transformation fails, leave the CSS unchanged
}
}
},
};
}
2 changes: 2 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ export async function createVite(
},
},
resolve: {
// Vite's native tsconfig path resolution; see configAliasVitePlugin for the deprecated fallback.
tsconfigPaths: true,
alias: [
{
// This is needed for Deno compatibility, as the non-browser version
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ export class FetchState implements AstroFetchState {
extraStyleHashes,
extraScriptHashes,
propagators: new Set(),
routeHasPropagation: false,
pendingSlotEvaluations: [],
templateDepth: 0,
},
cspDestination: manifest.csp?.cspDestination ?? (routeData.prerender ? 'meta' : 'header'),
Expand Down
56 changes: 44 additions & 12 deletions packages/astro/src/core/head-propagation/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ export interface HeadPropagator {
}

/**
* Runs all registered propagators and collects emitted head HTML strings.
* Runs all registered propagators and collects the head HTML they emit.
*
* This iterates the live `Set`, so propagators discovered during iteration
* are also processed in the same pass.
* Components with head content are discovered as we go. Initializing one
* propagator can register more of them: a component marked `in-tree` renders
* its children, and one of those children may be a `self` component that emits
* styles. Slots add a second way to find them — a slot whose markup contains an
* `await` only reaches the components after that `await` once it resolves, so
* we also wait for those pending slot pre-renders. We keep initializing
* propagators and waiting on slots until no new ones appear.
*
* Propagators are tracked in a `seen` set rather than read through a single
* live `Set` iterator. A propagator can be registered after we have already
* iterated to the end of the set (e.g. once a slot's `await` resolves), and a
* `Set` iterator that has reported `done` would never report those late
* additions.
*
* @example
* If a layout initializes and discovers a nested component that also emits
Expand All @@ -20,20 +31,41 @@ export async function collectPropagatedHeadParts(input: {
isHeadAndContent: (value: unknown) => value is { head: string };
}): Promise<string[]> {
const collectedHeadParts: string[] = [];
const seen = new Set<HeadPropagator>();
// Populated (only on propagation routes) by eager async slot pre-renders.
const pendingSlotEvaluations = input.result._metadata?.pendingSlotEvaluations ?? [];

// Keep the iterator live so newly-added propagators are seen.
const iterator = input.propagators.values();
while (true) {
const { value, done } = iterator.next();
if (done) {
break;
// 1. Drain async slot pre-renders first. Resolving them runs the slot
// markup past its `await`s, registering any propagators inside.
if (pendingSlotEvaluations.length > 0) {
const batch = pendingSlotEvaluations.splice(0, pendingSlotEvaluations.length);
await Promise.all(batch);
continue;
}

const returnValue = await value.init(input.result);
// Only collect explicit head-bearing return values.
if (input.isHeadAndContent(returnValue) && returnValue.head) {
collectedHeadParts.push(returnValue.head);
// 2. Initialize the next not-yet-seen propagator. `init()` may register
// further propagators or queue more slot evaluations.
let progressed = false;
for (const propagator of input.propagators) {
if (seen.has(propagator)) continue;
seen.add(propagator);
progressed = true;

const returnValue = await propagator.init(input.result);
// Only collect explicit head-bearing return values.
if (input.isHeadAndContent(returnValue) && returnValue.head) {
collectedHeadParts.push(returnValue.head);
}
// Only initialize one propagator per `for` pass, then break back to
// the `while`. This is not the same as letting the `for` run to its
// end: `init()` above may have queued new slot pre-renders, and
// breaking returns to step 1 so those are drained before the next
// propagator is initialized.
break;
}

if (!progressed) break;
}

return collectedHeadParts;
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/src/runtime/server/render/astro/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export class AstroComponentInstance {
// prerender the slots eagerly to make collection entries propagate styles and scripts
let didRender = false;
let value = slots[name](result);
// When a slot is async (contains `await` in its markup), the eager
// pre-render above only runs up to the first `await`, so any propagating
// component sequenced after it (e.g. a content collection `Content` with
// styles) is not registered yet. On propagation routes, track the pending
// pre-render so head buffering can await it and discover those propagators
// before flushing the head. See `collectPropagatedHeadParts`.
if (result._metadata.routeHasPropagation && isPromise(value)) {
result._metadata.pendingSlotEvaluations.push(value);
}
this.slotValues[name] = () => {
// use prerendered value only once
if (!didRender) {
Expand Down
14 changes: 10 additions & 4 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RouteData, SSRResult } from '../../../types/public/internal.js';
import { isRoute404, isRoute500 } from '../../../core/routing/internal/route-errors.js';
import { isPropagatingHint } from '../../../core/head-propagation/resolver.js';
import { renderToAsyncIterable, renderToReadableStream, renderToString } from './astro/render.js';
import { encoder } from './common.js';
import { type NonAstroPageComponent, renderComponentToString } from './component.js';
Expand All @@ -17,8 +18,9 @@ export async function renderPage(
route?: RouteData,
): Promise<Response> {
if (!isAstroComponentFactory(componentFactory)) {
result._metadata.headInTree =
result.componentMetadata.get((componentFactory as any).moduleId)?.containsHead ?? false;
const nonAstroMeta = result.componentMetadata.get((componentFactory as any).moduleId);
result._metadata.headInTree = nonAstroMeta?.containsHead ?? false;
result._metadata.routeHasPropagation = isPropagatingHint(nonAstroMeta?.propagation ?? 'none');

const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };

Expand Down Expand Up @@ -58,8 +60,12 @@ export async function renderPage(

// Mark if this page component contains a <head> within its tree. If it does
// We avoid implicit head injection entirely.
result._metadata.headInTree =
result.componentMetadata.get(componentFactory.moduleId!)?.containsHead ?? false;
const pageMeta = result.componentMetadata.get(componentFactory.moduleId!);
result._metadata.headInTree = pageMeta?.containsHead ?? false;
// Only routes on a propagation path need to await async slot pre-renders
// before flushing the head (see `collectPropagatedHeadParts`). Other routes
// keep streaming without blocking the head on unrelated markup `await`s.
result._metadata.routeHasPropagation = isPropagatingHint(pageMeta?.propagation ?? 'none');

let body: BodyInit | Response;
if (streaming) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SERVER_ISLAND_START = '[if astro]>server-island-start<![endif]';
Loading
Loading