From 31c4562cf6101e99c975a872010d73e2b970f164 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 05:08:05 -0700 Subject: [PATCH 1/4] fix(cloudflare): add namespace filter to astro-frontmatter-scan esbuild plugin Fixes #16203 --- .../cloudflare/src/esbuild-plugin-astro-frontmatter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/integrations/cloudflare/src/esbuild-plugin-astro-frontmatter.ts b/packages/integrations/cloudflare/src/esbuild-plugin-astro-frontmatter.ts index db8d117637ff..45812449edd6 100644 --- a/packages/integrations/cloudflare/src/esbuild-plugin-astro-frontmatter.ts +++ b/packages/integrations/cloudflare/src/esbuild-plugin-astro-frontmatter.ts @@ -17,7 +17,11 @@ export function astroFrontmatterScanPlugin(): ESBuildPlugin { return { name: 'astro-frontmatter-scan', setup(build) { - build.onLoad({ filter: /\.astro$/ }, async (args) => { + // Scope to the "file" namespace so that .astro files resolved into the + // "html" namespace (e.g. when a .ts file default-imports a component) + // fall through to Vite's built-in html-type handler, which appends + // `export default {}` and avoids "No matching export" errors. + build.onLoad({ filter: /\.astro$/, namespace: 'file' }, async (args) => { try { const code = await readFile(args.path, 'utf-8'); From 2296506c7e17f4373b7410faebed9017fb170bc8 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Fri, 10 Apr 2026 02:33:01 -0700 Subject: [PATCH 2/4] test(cloudflare): cover astro-frontmatter-scan namespace filter Adds a unit test for `astroFrontmatterScanPlugin` that asserts the plugin only registers `onLoad` handlers scoped to the `file` namespace, plus behavioural tests for frontmatter extraction, empty-frontmatter fallback, and the top-level `return` -> `throw` rewrite. The first case is a regression guard for the cross-namespace bug fixed in this PR. Also adds a changeset for `@astrojs/cloudflare`. --- .../fix-cf-frontmatter-scan-html-namespace.md | 5 + .../esbuild-plugin-astro-frontmatter.test.js | 111 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 .changeset/fix-cf-frontmatter-scan-html-namespace.md create mode 100644 packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js diff --git a/.changeset/fix-cf-frontmatter-scan-html-namespace.md b/.changeset/fix-cf-frontmatter-scan-html-namespace.md new file mode 100644 index 000000000000..afbb5e400b21 --- /dev/null +++ b/.changeset/fix-cf-frontmatter-scan-html-namespace.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes a Cloudflare adapter regression where importing an `.astro` component as a default export from a `.ts` file failed with `No matching export in "html:..." for import "default"`. The internal `astro-frontmatter-scan` esbuild plugin now scopes its `onLoad` handler to the `file` namespace, so `.astro` files resolved into Vite's `html` namespace fall through to Vite's built-in handler instead of being intercepted by the frontmatter-only loader. diff --git a/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js b/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js new file mode 100644 index 000000000000..7890007ec649 --- /dev/null +++ b/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js @@ -0,0 +1,111 @@ +import * as assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { astroFrontmatterScanPlugin } from '../dist/esbuild-plugin-astro-frontmatter.js'; + +/** + * Build a minimal fake esbuild `build` object that captures every `onLoad` + * call so the plugin's setup can be inspected without running esbuild. + */ +function captureOnLoadHandlers(plugin) { + const handlers = []; + plugin.setup({ + onLoad(options, callback) { + handlers.push({ options, callback }); + }, + }); + return handlers; +} + +describe('astroFrontmatterScanPlugin', () => { + let tmp; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'astro-frontmatter-scan-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('only registers onLoad handlers scoped to the "file" namespace', () => { + // Regression test for #16203: an unscoped `onLoad({ filter: /\.astro$/ })` + // also matches the `html` namespace, where Vite resolves `.astro` files + // when a `.ts` module default-imports them. The plugin then strips the + // component down to its frontmatter — which has no `export default` — + // causing `No matching export in "html:..." for import "default"` errors + // during dependency scanning. Scoping to `file` lets Vite's built-in + // html-type handler take over for the `html` namespace. + const handlers = captureOnLoadHandlers(astroFrontmatterScanPlugin()); + + assert.ok(handlers.length > 0, 'plugin should register at least one onLoad handler'); + for (const { options } of handlers) { + assert.equal( + options.namespace, + 'file', + `onLoad handler must declare namespace: "file" (got ${JSON.stringify(options.namespace)})`, + ); + } + }); + + it('extracts frontmatter contents from a real .astro file in the "file" namespace', async () => { + const astroPath = join(tmp, 'Component.astro'); + writeFileSync( + astroPath, + `--- +import { something } from 'some-package'; +const value = 1; +--- + +
{value}
+`, + ); + + const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); + const result = await callback({ path: astroPath, namespace: 'file' }); + + assert.equal(result.loader, 'ts'); + assert.match(result.contents, /import \{ something \} from 'some-package'/); + assert.match(result.contents, /const value = 1/); + assert.doesNotMatch( + result.contents, + /
/, + 'template body must not be present in the extracted frontmatter', + ); + }); + + it('returns empty contents for a .astro file with no frontmatter', async () => { + const astroPath = join(tmp, 'NoFrontmatter.astro'); + writeFileSync(astroPath, '
just markup, no frontmatter
\n'); + + const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); + const result = await callback({ path: astroPath, namespace: 'file' }); + + assert.equal(result.loader, 'ts'); + assert.equal(result.contents, ''); + }); + + it('rewrites top-level `return` statements to `throw` to avoid esbuild errors', async () => { + const astroPath = join(tmp, 'EarlyReturn.astro'); + writeFileSync( + astroPath, + `--- +const condition = true; +if (condition) { + return Astro.redirect('/elsewhere'); +} +--- + +
not reached
+`, + ); + + const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); + const result = await callback({ path: astroPath, namespace: 'file' }); + + assert.match(result.contents, /throw\s+Astro\.redirect/); + assert.doesNotMatch(result.contents, /\breturn\s+Astro\.redirect/); + }); +}); From 1bc8d2d00ca76d8451d3cfb21e00f934db27f1b3 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Fri, 10 Apr 2026 03:08:35 -0700 Subject: [PATCH 3/4] changeset: shorten cloudflare frontmatter-scan note per review feedback --- .changeset/fix-cf-frontmatter-scan-html-namespace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-cf-frontmatter-scan-html-namespace.md b/.changeset/fix-cf-frontmatter-scan-html-namespace.md index afbb5e400b21..c6e49f8e1899 100644 --- a/.changeset/fix-cf-frontmatter-scan-html-namespace.md +++ b/.changeset/fix-cf-frontmatter-scan-html-namespace.md @@ -2,4 +2,4 @@ '@astrojs/cloudflare': patch --- -Fixes a Cloudflare adapter regression where importing an `.astro` component as a default export from a `.ts` file failed with `No matching export in "html:..." for import "default"`. The internal `astro-frontmatter-scan` esbuild plugin now scopes its `onLoad` handler to the `file` namespace, so `.astro` files resolved into Vite's `html` namespace fall through to Vite's built-in handler instead of being intercepted by the frontmatter-only loader. +Fixes `.astro` files failing with `No matching export in "html:..." for import "default"` when default-imported from a `.ts` file From 80e3e01732deef55605d762c36aa8569ed8b871c Mon Sep 17 00:00:00 2001 From: barry3406 Date: Fri, 10 Apr 2026 06:01:39 -0700 Subject: [PATCH 4/4] test(cloudflare): swap frontmatter-scan unit test for integration test Per review feedback. The unit test only asserted that the plugin registers `namespace: 'file'`; it never verified that the "No matching export in 'html:...' for import 'default'" error from #16203 actually disappears. The new fixture mirrors the issue reproduction: a `lib/ui.ts` registry default-imports `Inner.astro` and `Outer.astro` (Outer wraps Inner), consumed by a page. The test runs a build through `loadFixture` and captures the Astro logger output, asserting that neither the "No matching export" line nor "Failed to run dependency scan" appears. Removing `namespace: 'file'` makes both assertions fire with the exact error message from #16203. --- .../esbuild-plugin-astro-frontmatter.test.js | 111 ------------------ .../fixtures/ts-astro-import/astro.config.mjs | 7 ++ .../src/components/Inner.astro | 5 + .../src/components/Outer.astro | 7 ++ .../fixtures/ts-astro-import/src/lib/ui.ts | 13 ++ .../ts-astro-import/src/pages/index.astro | 11 ++ .../cloudflare/test/ts-astro-import.test.js | 70 +++++++++++ 7 files changed, 113 insertions(+), 111 deletions(-) delete mode 100644 packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js create mode 100644 packages/integrations/cloudflare/test/fixtures/ts-astro-import/astro.config.mjs create mode 100644 packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Inner.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Outer.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/lib/ui.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/pages/index.astro create mode 100644 packages/integrations/cloudflare/test/ts-astro-import.test.js diff --git a/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js b/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js deleted file mode 100644 index 7890007ec649..000000000000 --- a/packages/integrations/cloudflare/test/esbuild-plugin-astro-frontmatter.test.js +++ /dev/null @@ -1,111 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; -import { astroFrontmatterScanPlugin } from '../dist/esbuild-plugin-astro-frontmatter.js'; - -/** - * Build a minimal fake esbuild `build` object that captures every `onLoad` - * call so the plugin's setup can be inspected without running esbuild. - */ -function captureOnLoadHandlers(plugin) { - const handlers = []; - plugin.setup({ - onLoad(options, callback) { - handlers.push({ options, callback }); - }, - }); - return handlers; -} - -describe('astroFrontmatterScanPlugin', () => { - let tmp; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), 'astro-frontmatter-scan-')); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it('only registers onLoad handlers scoped to the "file" namespace', () => { - // Regression test for #16203: an unscoped `onLoad({ filter: /\.astro$/ })` - // also matches the `html` namespace, where Vite resolves `.astro` files - // when a `.ts` module default-imports them. The plugin then strips the - // component down to its frontmatter — which has no `export default` — - // causing `No matching export in "html:..." for import "default"` errors - // during dependency scanning. Scoping to `file` lets Vite's built-in - // html-type handler take over for the `html` namespace. - const handlers = captureOnLoadHandlers(astroFrontmatterScanPlugin()); - - assert.ok(handlers.length > 0, 'plugin should register at least one onLoad handler'); - for (const { options } of handlers) { - assert.equal( - options.namespace, - 'file', - `onLoad handler must declare namespace: "file" (got ${JSON.stringify(options.namespace)})`, - ); - } - }); - - it('extracts frontmatter contents from a real .astro file in the "file" namespace', async () => { - const astroPath = join(tmp, 'Component.astro'); - writeFileSync( - astroPath, - `--- -import { something } from 'some-package'; -const value = 1; ---- - -
{value}
-`, - ); - - const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); - const result = await callback({ path: astroPath, namespace: 'file' }); - - assert.equal(result.loader, 'ts'); - assert.match(result.contents, /import \{ something \} from 'some-package'/); - assert.match(result.contents, /const value = 1/); - assert.doesNotMatch( - result.contents, - /
/, - 'template body must not be present in the extracted frontmatter', - ); - }); - - it('returns empty contents for a .astro file with no frontmatter', async () => { - const astroPath = join(tmp, 'NoFrontmatter.astro'); - writeFileSync(astroPath, '
just markup, no frontmatter
\n'); - - const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); - const result = await callback({ path: astroPath, namespace: 'file' }); - - assert.equal(result.loader, 'ts'); - assert.equal(result.contents, ''); - }); - - it('rewrites top-level `return` statements to `throw` to avoid esbuild errors', async () => { - const astroPath = join(tmp, 'EarlyReturn.astro'); - writeFileSync( - astroPath, - `--- -const condition = true; -if (condition) { - return Astro.redirect('/elsewhere'); -} ---- - -
not reached
-`, - ); - - const [{ callback }] = captureOnLoadHandlers(astroFrontmatterScanPlugin()); - const result = await callback({ path: astroPath, namespace: 'file' }); - - assert.match(result.contents, /throw\s+Astro\.redirect/); - assert.doesNotMatch(result.contents, /\breturn\s+Astro\.redirect/); - }); -}); diff --git a/packages/integrations/cloudflare/test/fixtures/ts-astro-import/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/astro.config.mjs new file mode 100644 index 000000000000..339f0e2a49c0 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/astro.config.mjs @@ -0,0 +1,7 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + adapter: cloudflare(), + output: 'server', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Inner.astro b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Inner.astro new file mode 100644 index 000000000000..f701dcb883f5 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Inner.astro @@ -0,0 +1,5 @@ +--- +const message = 'hello from inner'; +--- + +{message} diff --git a/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Outer.astro b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Outer.astro new file mode 100644 index 000000000000..1f76020eecbc --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/components/Outer.astro @@ -0,0 +1,7 @@ +--- +import Inner from './Inner.astro'; +--- + +
+ +
diff --git a/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/lib/ui.ts b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/lib/ui.ts new file mode 100644 index 000000000000..f5e449a43cdd --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/lib/ui.ts @@ -0,0 +1,13 @@ +// A .ts file that default-imports .astro components — the same pattern +// used by @storyblok/astro's virtual:import-storyblok-components. +// +// During esbuild dep scanning, these .astro imports land in the "html" +// namespace. Without `namespace: "file"` on the astro-frontmatter-scan +// onLoad handler, the plugin intercepts the load and returns only the +// frontmatter — which has no `export default` — breaking the import with +// `No matching export in "html:..." for import "default"`. Regression +// guard for #16203. +import Inner from '../components/Inner.astro'; +import Outer from '../components/Outer.astro'; + +export const components = { Inner, Outer }; diff --git a/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/pages/index.astro new file mode 100644 index 000000000000..d2d5c3c4ab1f --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/ts-astro-import/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +import { components } from '../lib/ui'; + +const Outer = components.Outer; +--- + + + + + + diff --git a/packages/integrations/cloudflare/test/ts-astro-import.test.js b/packages/integrations/cloudflare/test/ts-astro-import.test.js new file mode 100644 index 000000000000..c31c8c365f2d --- /dev/null +++ b/packages/integrations/cloudflare/test/ts-astro-import.test.js @@ -0,0 +1,70 @@ +import { rmSync } from 'node:fs'; +import { describe, before, it } from 'node:test'; +import { Writable } from 'node:stream'; +import { loadFixture } from './_test-utils.js'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { AstroLogger } from '../../../astro/dist/core/logger/core.js'; + +describe('ts file default-importing an .astro component', () => { + /** @type {import('../../../astro/test/test-utils').Fixture} */ + let fixture; + const logs = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ts-astro-import/', + }); + + // Clear the Vite cache so dep optimization runs from scratch + // and the esbuild scan actually exercises the plugin path under test. + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + await fixture.build({ + vite: { logLevel: 'error' }, + logger: new AstroLogger({ + level: 'error', + destination: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + }); + + it('should not produce "No matching export" error when a .ts module default-imports a .astro component', async () => { + // Regression test for #16203. Without `namespace: 'file'` on the + // astro-frontmatter-scan onLoad handler, Vite's dep scanner resolves + // `.astro` files into the `html` namespace and the plugin still + // intercepts them, returning only the frontmatter (no `export default`) + // and producing: + // No matching export in "html:/.../Component.astro" for import "default" + const noMatchingExportLog = logs.find( + (log) => + log.message && + log.message.includes('No matching export') && + log.message.includes('html:') && + log.message.includes('for import "default"'), + ); + + assert.ok( + !noMatchingExportLog, + `Should not see "No matching export in 'html:...' for import 'default'" message, but got: ${noMatchingExportLog?.message}`, + ); + }); + + it('should complete dependency scanning successfully', async () => { + const dependencyScanFailedLog = logs.find( + (log) => log.message && log.message.includes('Failed to run dependency scan'), + ); + + assert.ok( + !dependencyScanFailedLog, + `Should not see "Failed to run dependency scan" message, but got: ${dependencyScanFailedLog?.message}`, + ); + }); +});