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/shiny-llamas-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes an issue where requests handled by the dev prerender environment (e.g. `/_image` with `@astrojs/cloudflare`'s `prerenderEnvironment: 'node'`) returned a 500 when a prerendered catch-all route existed, because non-prerendered route modules were imported in an environment where their runtime-specific APIs are unavailable
5 changes: 5 additions & 0 deletions .changeset/tidy-jokes-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `experimental.incrementalBuild` re-rendering unchanged routes that import more than one asset. The route's dependency hash depended on the order the assets finished building, so two builds of identical sources could produce different hashes. The hash is now based on the file name each asset resolves to.
5 changes: 5 additions & 0 deletions .changeset/two-actors-go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves `getCollection()` and `getEntry()` performance for entries without local image references
5 changes: 5 additions & 0 deletions .changeset/yellow-pants-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a build error caused by hash collisions in generated content collection image import identifiers
4 changes: 0 additions & 4 deletions packages/astro/src/assets/utils/resolveImports.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path';
import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js';
import { shorthash } from '../../runtime/server/shorthash.js';
import { VALID_INPUT_FORMATS } from '../consts.js';

/**
Expand Down Expand Up @@ -39,6 +38,3 @@ export function imageSrcToImportId(imageSrc: string, filePath?: string): string
}
return `${imageSrc}?${params.toString()}`;
}

export const importIdToSymbolName = (importId: string) =>
`__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`;
7 changes: 5 additions & 2 deletions packages/astro/src/content/loaders/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ interface GlobOptions {

function generateIdDefault({ entry, base, data }: GenerateIdOptions, isLegacy?: boolean): string {
if (data.slug) {
return data.slug as string;
return String(data.slug);
}
const entryURL = new URL(encodeURI(entry), base);
if (isLegacy) {
Expand Down Expand Up @@ -94,8 +94,11 @@ export function glob(globOptions: GlobOptions & { [secretLegacyFlag]?: boolean }
}

const isLegacy = !!globOptions[secretLegacyFlag];
const generateId =
const userGenerateId =
globOptions?.generateId ?? ((opts: GenerateIdOptions) => generateIdDefault(opts, isLegacy));
// Coerce to string so numeric ids from YAML don't cause Set strict-equality mismatches
// against string store keys in the untouched-entries cleanup. See #17624.
const generateId = (opts: GenerateIdOptions) => String(userGenerateId(opts));

const fileToIdMap = new Map<string, string>();

Expand Down
6 changes: 3 additions & 3 deletions packages/astro/src/content/mutable-data-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync, promises as fs, type PathLike } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as devalue from 'devalue';
import { forEach } from 'neotraverse';
import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js';
import { imageSrcToImportId } from '../assets/utils/resolveImports.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { DATA_STORE_MANIFEST_FILE, IMAGE_IMPORT_PREFIX } from './consts.js';
import {
Expand Down Expand Up @@ -145,8 +145,8 @@ export class MutableDataStore extends ImmutableDataStore {
const exports: Array<string> = [];
// Sort asset imports to ensure deterministic output across builds
const sortedAssetImports = [...this.#assetImports].sort();
sortedAssetImports.forEach((id) => {
const symbol = importIdToSymbolName(id);
sortedAssetImports.forEach((id, index) => {
const symbol = `__ASTRO_IMAGE_IMPORT_${index}`;
imports.push(`import ${symbol} from ${JSON.stringify(id)};`);
exports.push(`[${JSON.stringify(id)}, ${symbol}]`);
});
Expand Down
13 changes: 11 additions & 2 deletions packages/astro/src/content/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function createGetCollection({

const result = [];
for (const rawEntry of await store.values<DataEntry>(collection)) {
const data = updateImageReferencesInData(rawEntry.data, rawEntry.filePath, imageAssetMap);
const data = resolveEntryData(rawEntry, imageAssetMap);

let entry = {
...rawEntry,
Expand Down Expand Up @@ -209,7 +209,7 @@ export function createGetEntry({ liveCollections }: { liveCollections: LiveColle

// @ts-expect-error virtual module
const { default: imageAssetMap } = await import('astro:asset-imports');
const data = updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap);
const data = resolveEntryData(entry, imageAssetMap);
const result = {
...entry,
data,
Expand Down Expand Up @@ -558,6 +558,15 @@ export function updateImageReferencesInData<T extends Record<string, unknown>>(
return copy;
}

export function resolveEntryData<T extends Record<string, unknown>>(
entry: DataEntry<T>,
imageAssetMap?: Map<string, ImageMetadata>,
): T {
return entry.assetImports?.length
? updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap)
: structuredClone(entry.data);
}

export async function renderEntry(entry: DataEntry) {
if (!entry) {
throw new AstroError(AstroErrorData.RenderUndefinedEntryError);
Expand Down
36 changes: 34 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface HashableModuleInfo {

interface ModuleGraph {
getModuleInfo(id: string): HashableModuleInfo | null;
getFileName(referenceId: string): string;
}

/** Collect the sorted, transitive dependency ids of a module, following static and dynamic imports. */
Expand All @@ -46,11 +47,42 @@ function collectTransitiveDeps(graph: ModuleGraph, rootId: string): string[] {
return [...deps].sort();
}

/** Each placeholder pattern paired with the token that has to be present for it to match. */
const ASSET_PLACEHOLDERS = [
{ token: '__ASTRO_ASSET_IMAGE__', pattern: /__ASTRO_ASSET_IMAGE__([\w$]+)__(?:_(.*?)__)?/g },
{ token: '__VITE_ASSET__', pattern: /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g },
];

/**
* Replace the emit handles of imported assets with the file names they resolve
* to. Handles are assigned by the bundler in the order modules finish
* transforming, so two builds of the same sources can hand the same asset a
* different handle, while the file name it resolves to is content-hashed and
* stable. If a handle does not resolve to a file, the placeholder is left as
* it is; the worst case is an unnecessary re-render, not a stale one.
*/
function resolveAssetPlaceholders(graph: ModuleGraph, code: string): string {
let resolved = code;
for (const { token, pattern } of ASSET_PLACEHOLDERS) {
if (!resolved.includes(token)) continue;
resolved = resolved.replace(pattern, (placeholder, handle, postfix = '') => {
try {
return graph.getFileName(handle) + postfix;
} catch {
return placeholder;
}
});
}
return resolved;
}

/**
* Hash a sorted set of module ids together with each module's compiled output.
* Hashing the transformed `code` from the bundle (rather than the source file on
* disk) reflects what actually ships and covers virtual modules, which have no
* file on disk but still carry generated code.
* file on disk but still carry generated code. Emitted-asset placeholders in
* that code are resolved to their file names first, since the handles
* themselves are not stable between builds.
*/
function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
const hasher = crypto.createHash('sha256');
Expand All @@ -59,7 +91,7 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
hasher.update('\n');
const code = graph.getModuleInfo(id)?.code;
if (code != null) {
hasher.update(code);
hasher.update(resolveAssetPlaceholders(graph, code));
}
hasher.update('\n');
}
Expand Down
20 changes: 19 additions & 1 deletion packages/astro/src/core/routing/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function matchRoute(
routesList: RoutesList,
pipeline: RunnablePipeline,
manifest: SSRManifest,
{ prerenderOnly }: { prerenderOnly?: boolean } = {},
): Promise<MatchedRoute | undefined> {
const { logger, routeCache } = pipeline;
const matches = matchAllRoutes(pathname, routesList);
Expand All @@ -34,7 +35,16 @@ export async function matchRoute(
});

let firstError: unknown = null;
let skippedPrerenderOnly = false;
for await (const { route: maybeRoute, filePath } of preloadedMatches) {
// When running as the prerender handler, skip non-prerendered routes
// before importing their components. Their modules may use runtime-
// specific APIs (e.g. cloudflare:workers) unavailable in the prerender
// environment.
if (prerenderOnly && !maybeRoute.prerender) {
skippedPrerenderOnly = true;
continue;
}
// attempt to get static paths
// if this fails, we have a bad URL match!
try {
Expand Down Expand Up @@ -78,7 +88,15 @@ export async function matchRoute(
const altPathname = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');

if (altPathname !== pathname) {
return await matchRoute(altPathname, routesList, pipeline, manifest);
return await matchRoute(altPathname, routesList, pipeline, manifest, { prerenderOnly });
}

// A non-prerendered route matched but was skipped above. Don't warn or fall
// back to the 404 route (which may be prerendered and would shadow the SSR
// route): returning undefined lets the caller mark the request as not
// handled, so it falls through to the SSR handler and its own full matching.
if (skippedPrerenderOnly) {
return undefined;
}

if (matches.length) {
Expand Down
8 changes: 6 additions & 2 deletions packages/astro/src/vite-plugin-app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,16 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
this.pipeline.clearActions();
}

async devMatch(pathname: string): Promise<DevMatch | undefined> {
async devMatch(
pathname: string,
{ prerenderOnly }: { prerenderOnly?: boolean } = {},
): Promise<DevMatch | undefined> {
const matchedRoute = await matchRoute(
pathname,
this.manifestData,
this.pipeline as unknown as RunnablePipeline,
this.manifest,
{ prerenderOnly },
);
if (!matchedRoute) {
return undefined;
Expand Down Expand Up @@ -200,7 +204,7 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
controller,
pathname,
async run() {
const matchedRoute = await self.devMatch(pathname);
const matchedRoute = await self.devMatch(pathname, { prerenderOnly });
if (!matchedRoute) {
if (prerenderOnly) {
// In prerender-only mode, signal that we didn't handle this
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Numeric Slug Entry
slug: 20260624
---

Entry with an unquoted numeric slug value.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
import { getCount } from "../utils/notImportedByMiddleware";
const title = Astro.locals.utils.arrayToString(["Hello", "Astro"]);
Astro.response.headers.set("x-index-count", getCount());
---

<html lang="en">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
let count = 0;

export const getCount = () => {
count += 1;
return count;
}
17 changes: 17 additions & 0 deletions packages/astro/test/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,21 @@ describe('Middleware HMR', () => {
assert.equal(response.headers.get('x-test-executed'), '1');
assert.equal(response.headers.get('x-test-other-count'), '1');
});

it("shouldn't reload middleware.js when an unrelated module is reloaded", async () => {
let response = await fetchAndAssertTwice();
assert.equal(response.headers.get('x-index-count'), '2');

await fixture.editFile('./src/utils/notImportedByMiddleware.js', (original) =>
original.replace('let count = 0;', 'let count = 0;\n//foo\n'),
);
await new Promise((resolve) => setTimeout(resolve, 2000));

// src/utils/notImportedByMiddleware.js should reload
// src/middleware.js shouldn't reload, and neither should src/utils/other.js
response = await fixture.fetch('/');
assert.equal(response.headers.get('x-test-executed'), '3');
assert.equal(response.headers.get('x-test-other-count'), '3');
assert.equal(response.headers.get('x-index-count'), '1');
});
});
Loading
Loading