diff --git a/.changeset/fix-app-match-malformed-uri.md b/.changeset/fix-app-match-malformed-uri.md new file mode 100644 index 000000000000..e6858fde45e5 --- /dev/null +++ b/.changeset/fix-app-match-malformed-uri.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Prevents `App.match()` from throwing on request paths that contain an invalid percent-sequence. diff --git a/.changeset/fix-client-hmr-program-reload.md b/.changeset/fix-client-hmr-program-reload.md new file mode 100644 index 000000000000..00daf20677c3 --- /dev/null +++ b/.changeset/fix-client-hmr-program-reload.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where editing a client-side component (e.g. with `client:idle`, `client:load`, etc.) caused an unnecessary full program reload of the backend during development. diff --git a/.changeset/fix-endpoint-html-params.md b/.changeset/fix-endpoint-html-params.md new file mode 100644 index 000000000000..f0f5fe88ff9b --- /dev/null +++ b/.changeset/fix-endpoint-html-params.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a bug where static file endpoints using `getStaticPaths` with `.html` in dynamic param values (e.g. `{ path: 'file.html' }`) would fail with a `NoMatchingStaticPathFound` error during build. The `.html` suffix is no longer incorrectly stripped from endpoint route pathnames. diff --git a/.changeset/lazy-jeans-brake.md b/.changeset/lazy-jeans-brake.md new file mode 100644 index 000000000000..d49080a13bb1 --- /dev/null +++ b/.changeset/lazy-jeans-brake.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fix an issue where dynamic routes would return the string `[object Object]` instead of the expected content, in certain runtimes. diff --git a/.changeset/normalize-filename-case.md b/.changeset/normalize-filename-case.md new file mode 100644 index 000000000000..f004c318e59d --- /dev/null +++ b/.changeset/normalize-filename-case.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes styles being stripped when the project root is started with a path whose case differs from the actual filesystem case (e.g. running `astro dev` from `d:\dev\app` while the folder on disk is `D:\dev\app`). diff --git a/.changeset/satteri-gfm-math-options.md b/.changeset/satteri-gfm-math-options.md new file mode 100644 index 000000000000..e09777b0c7a3 --- /dev/null +++ b/.changeset/satteri-gfm-math-options.md @@ -0,0 +1,6 @@ +--- +'@astrojs/markdown-satteri': patch +'@astrojs/mdx': patch +--- + +Updates Sätteri processor to v0.8.0. See [its changelog](https://github.com/bruits/satteri/blob/main/packages/satteri/CHANGELOG.md#080--2026-06-03) for details on bugs fixed and features added. diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 37f2b9e69a99..aa5670c7a8d7 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -261,20 +261,35 @@ export abstract class BaseApp
{ } /** - * Extracts the base-stripped, decoded pathname from a request. - * Used by adapters to compute the pathname for dev-mode route matching. + * Decodes a pathname with `decodeURI`, falling back to the raw pathname when it + * contains an invalid percent-sequence (e.g. `%C0%AF`, an overlong-UTF-8 encoding of + * `/` commonly sent by path-traversal scanners). A raw `decodeURI()` would throw + * `URIError: URI malformed`, and because `match()` runs before `render()` that error + * escapes the adapter's request handler as an uncaught exception (HTTP 500) that user + * middleware can't catch. */ - public getPathnameFromRequest(request: Request): string { - const url = new URL(request.url); - const pathname = prependForwardSlash(this.removeBase(url.pathname)); + private safeDecodeURI(pathname: string): string { try { return decodeURI(pathname); } catch (e: any) { - this.adapterLogger.error(e.toString()); + // Malformed request paths are expected client input (commonly from automated + // scanners) rather than a server fault, and this runs per-request on the hot + // path. Log at `debug` so it stays diagnosable without flooding error logs. + this.adapterLogger.debug(e.toString()); return pathname; } } + /** + * Extracts the base-stripped, decoded pathname from a request. + * Used by adapters to compute the pathname for dev-mode route matching. + */ + public getPathnameFromRequest(request: Request): string { + const url = new URL(request.url); + const pathname = prependForwardSlash(this.removeBase(url.pathname)); + return this.safeDecodeURI(pathname); + } + /** * Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered * routes aren't returned, even if they are matched. @@ -291,7 +306,7 @@ export abstract class BaseApp
{ if (!pathname) { pathname = prependForwardSlash(this.removeBase(url.pathname)); } - const routeData = this.pipeline.matchRoute(decodeURI(pathname)); + const routeData = this.pipeline.matchRoute(this.safeDecodeURI(pathname)); if (!routeData) return undefined; if (allowPrerenderedRoutes) { return routeData; @@ -303,7 +318,7 @@ export abstract class BaseApp
{ // the same pattern should handle all other URLs. if (routeData.prerender) { if (routeData.params.length > 0) { - const allMatches = this.pipeline.matchAllRoutes(decodeURI(pathname)); + const allMatches = this.pipeline.matchAllRoutes(this.safeDecodeURI(pathname)); return allMatches.find((r) => !r.prerender); } return undefined; @@ -444,7 +459,7 @@ export abstract class BaseApp
{
if (!routeData) {
const domainPathname = this.computePathnameFromDomain(request);
if (domainPathname) {
- routeData = this.pipeline.matchRoute(decodeURI(domainPathname));
+ routeData = this.pipeline.matchRoute(this.safeDecodeURI(domainPathname));
}
}
const resolvedOptions: ResolvedRenderOptions = {
diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts
index 7dce1b13bc8b..d3cfcc352e07 100644
--- a/packages/astro/src/core/errors/dev/vite.ts
+++ b/packages/astro/src/core/errors/dev/vite.ts
@@ -166,7 +166,7 @@ export async function getViteErrorPayload(err: ErrorWithMetadata): PromisePage
`;
+});
+
+const pageModule = async () => ({
+ page: async () => ({
+ default: page,
+ }),
+});
+
+const pageMap = new Map([[indexRouteData.component, pageModule]]);
+
+const app = new App(
+ createManifest({
+ routes: [createRouteInfo(indexRouteData)],
+ pageMap: pageMap as any,
+ }) as any,
+);
+
+describe('Malformed URI handling in App.match', () => {
+ it('match() does not throw on an invalid percent-sequence', () => {
+ const request = new Request('http://example.com/%C0%AF');
+ assert.doesNotThrow(() => app.match(request));
+ assert.equal(app.match(request), undefined, 'no route should match a malformed path');
+ });
+
+ it('render() returns a 404 for a malformed percent-sequence', async () => {
+ const request = new Request('http://example.com/%C0%AF');
+ const response = await app.render(request);
+ assert.equal(
+ response.status,
+ 404,
+ 'a malformed path must resolve to a normal 404, not an uncaught 500',
+ );
+ });
+
+ it('valid routes still match', () => {
+ const request = new Request('http://example.com/');
+ assert.ok(app.match(request), '/ should still match');
+ });
+});
diff --git a/packages/astro/test/units/fetch/index.test.ts b/packages/astro/test/units/fetch/index.test.ts
index 1bf745f7b6e0..3b37f6410ee6 100644
--- a/packages/astro/test/units/fetch/index.test.ts
+++ b/packages/astro/test/units/fetch/index.test.ts
@@ -14,7 +14,7 @@ import {
import { ALL_PIPELINE_FEATURES } from '../../../dist/core/base-pipeline.js';
import { createComponent, render } from '../../../dist/runtime/server/index.js';
import { createEndpoint, createPage, createRedirect, createTestApp } from '../mocks.ts';
-import { dynamicPart } from '../routing/test-helpers.ts';
+import { dynamicPart, spreadPart } from '../routing/test-helpers.ts';
/** A simple page component that renders `Hello
`. */
const simplePage = createComponent((_result: any, _props: any, _slots: any) => {
@@ -90,6 +90,28 @@ describe('FetchState (astro/fetch)', () => {
assert.ok(state.routeData, 'routeData should fall back to the 404 route');
assert.equal(state.routeData!.route, '/404');
});
+
+ it('preserves .html in pathname for endpoint routes with dynamic params', () => {
+ // Regression test for #16941: when a dynamic endpoint returns a param
+ // value like `file.html`, the `.html` suffix must not be stripped from
+ // the pathname. Only page routes should have `.html` stripped (it is
+ // framework-injected there), but for endpoints the suffix is user-provided.
+ const endpoint = createEndpoint(
+ { GET: () => new Response('ok') },
+ {
+ route: '/[...path]',
+ pathname: undefined,
+ segments: [[spreadPart('path')]],
+ },
+ );
+ const app = createTestApp([endpoint]);
+ const request = stampApp(new Request('http://example.com/file.html'), app);
+ const state = new FetchState(request);
+
+ assert.ok(state.routeData, 'routeData should be set');
+ assert.equal(state.routeData!.type, 'endpoint');
+ assert.equal(state.pathname, '/file.html', '.html should be preserved for endpoint routes');
+ });
});
// #endregion
diff --git a/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts b/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts
index 72b14a6f0af1..f3f1d3b2652a 100644
--- a/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts
+++ b/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts
@@ -168,6 +168,7 @@ describe('astro:hmr-reload', () => {
const result = ctx.call();
assert.ok(Array.isArray(result), 'should return an array');
assert.equal(result.length, 0, 'should return empty array for styles');
+ assert.equal(ctx.wsSent.length, 0, 'should NOT send full-reload for style modules');
});
it('invalidates importers in the module graph for dynamic import chains', () => {
@@ -209,4 +210,40 @@ describe('astro:hmr-reload', () => {
assert.equal(ctx.invalidated.length, 1, 'should only invalidate SSR-only module');
assert.equal(ctx.invalidated[0], ssrMod);
});
+
+ it('returns [] for modules that exist in the client module graph (prevents unnecessary program reload)', () => {
+ const mod = createMockModule('/src/components/Foo.tsx');
+ const ctx = createMockContext({
+ environmentName: 'ssr',
+ modules: [mod],
+ clientModuleIds: ['/src/components/Foo.tsx'], // module IS in client graph
+ });
+
+ const result = ctx.call();
+
+ assert.deepEqual(
+ result,
+ [],
+ 'Should return empty array to prevent Vite default HMR propagation',
+ );
+ assert.equal(ctx.wsSent.length, 0, 'Should NOT send full-reload via WebSocket');
+ assert.equal(ctx.hotSent.length, 0, 'Should NOT send full-reload via hot channel');
+ assert.equal(ctx.invalidated.length, 0, 'Should NOT invalidate client-graph modules');
+ });
+
+ it('sends full-reload when mix of client and SSR-only modules', () => {
+ const clientMod = createMockModule('/src/components/Foo.tsx');
+ const ssrOnlyMod = createMockModule('/src/backend/db.ts');
+ const ctx = createMockContext({
+ environmentName: 'ssr',
+ modules: [clientMod, ssrOnlyMod],
+ clientModuleIds: ['/src/components/Foo.tsx'], // only Foo.tsx in client graph
+ });
+
+ const result = ctx.call();
+
+ assert.deepEqual(result, [], 'Should return empty array');
+ assert.equal(ctx.wsSent.length, 1, 'Should send full-reload because of SSR-only module');
+ assert.equal(ctx.wsSent[0].type, 'full-reload');
+ });
});
diff --git a/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts b/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts
new file mode 100644
index 000000000000..ff1b64f44d90
--- /dev/null
+++ b/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts
@@ -0,0 +1,45 @@
+import * as assert from 'node:assert/strict';
+import * as path from 'node:path';
+import { describe, it } from 'node:test';
+import { pathToFileURL } from 'node:url';
+import { normalizeFilename } from '../../../dist/vite-plugin-utils/index.js';
+
+// Build a fixture path that is absolute on both POSIX and Windows. On POSIX,
+// `path.resolve('/Users/me/project')` is `/Users/me/project`; on Windows it
+// becomes something like `D:\\Users\\me\\project` (the CWD drive gets
+// prepended). Using this lets tests that pass the resolved path to Node's URL
+// machinery behave identically on both platforms.
+const projectRoot = path.resolve('/Users/me/project');
+const projectRootUrl = pathToFileURL(projectRoot + path.sep);
+// `normalizeFilename` returns paths with forward slashes (it runs the result
+// through `viteID`/`slash`), so build expectations the same way.
+const projectRootSlash = projectRoot.replaceAll(path.sep, '/');
+
+describe('normalizeFilename', () => {
+ it('strips the /@fs prefix from filesystem paths', () => {
+ const root = pathToFileURL('/Users/me/project/');
+ const result = normalizeFilename('/@fs/Users/me/project/src/pages/index.astro', root);
+ assert.equal(result, '/Users/me/project/src/pages/index.astro');
+ });
+
+ it('resolves relative paths against root', () => {
+ const result = normalizeFilename('./src/components/Foo.astro', projectRootUrl);
+ assert.equal(result, `${projectRootSlash}/src/components/Foo.astro`);
+ });
+
+ it('preserves absolute paths that live inside root', () => {
+ const root = pathToFileURL('/Users/me/project/');
+ const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
+ assert.equal(result, '/Users/me/project/src/pages/index.astro');
+ });
+
+ it('preserves absolute paths when their case differs from root (issue #14013)', () => {
+ // Reproduces the case-insensitive filesystem scenario (Windows or macOS) where
+ // the user starts the dev server from a path whose case differs from disk.
+ // `root` comes from process.cwd() with one case, but Vite resolves modules with
+ // the canonical filesystem case. The two must still be treated as the same path.
+ const root = pathToFileURL('/users/me/project/');
+ const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
+ assert.equal(result, '/Users/me/project/src/pages/index.astro');
+ });
+});
diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json
index c0e62f77faa1..6d18eb293ba6 100644
--- a/packages/integrations/mdx/package.json
+++ b/packages/integrations/mdx/package.json
@@ -76,7 +76,7 @@
"remark-math": "^6.0.0",
"remark-rehype": "^11.1.2",
"remark-toc": "^9.0.0",
- "satteri": "^0.6.3",
+ "satteri": "^0.8.0",
"shiki": "^4.0.2",
"unified": "^11.0.5",
"vite": "^7.3.2"
diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts
index 8099ccdad282..e9eb427dd36a 100644
--- a/packages/integrations/mdx/src/index.ts
+++ b/packages/integrations/mdx/src/index.ts
@@ -174,7 +174,10 @@ export default function mdx(partialMdxOptions: Partial